简介:这是一套面向高校学生与开发者的手语识别系统完整项目源码,基于Python、OpenCV、JavaScript与MySQL构建,适合作为毕业设计或课程设计参考。系统通过摄像头实时捕获手势,结合神经网络模型完成识别并转换为文字输出,同时支持用户上传手势视频自定义训练模型,覆盖数据预处理、模型训练、降噪优化到实时识别的完整流程。压缩包共85个文件,约30.17MB,包含11个Python脚本负责模型训练与后端服务、16个HTML页面搭建前端交互界面、4个JavaScript文件处理识别逻辑与上传功能,另有1个pth模型文件可直接加载使用,以及若干jpg、png图片与css样式资源。项目已吸引73人学习,内含项目说明文档,目录结构清晰,便于快速理解系统架构与模块划分,适合需要完整手语识别方案、模型文件及前后端代码的读者参考借鉴。
1. 手语识别系统到底在识别什么:从一段 3 秒视频到一条数据库记录
很多人第一次听到「手语识别系统」,脑子里浮现的是摄像头对着人,屏幕上实时蹦出中文句子。真动手做才发现,难点根本不在「识别」两个字,而在把一段连续的手部动作切成机器能吃的样本,再把它映射成一个稳定的标签。我做过一套基于 Python + OpenCV + JavaScript + MySQL 的手语识别系统,输入是摄像头或本地视频,输出是识别结果落库、前端页面能查历史记录。它适合毕设课设,也适合想跑通「视觉模型 + 后端 + 前端 + 数据库」全链路的人。
这套系统真正解决的问题是:把孤立词手语(比如「你好」「谢谢」「帮助」这类单手势)从视频流里检测出来、分类、存下来、展示出来。它不适合做连续手语句子翻译,那是另一个量级的任务。下面我按「数据怎么来 → 模型怎么训 → 后端怎么接 → 前端怎么用 → 坑在哪」的顺序讲清楚,每一步都给能直接抄的命令和参数。
2. 数据采集与预处理:为什么你的手语数据集总是不够用
2.1 先定标签体系,再开摄像头
新手最容易翻车的地方是:先拍了几百段视频,回头发现标签乱成一锅粥。我一般会先定一个封闭词表,比如 10~20 个常用孤立词,每个词对应一个英文标签(避免中文路径在 OpenCV 里出玄学问题)。目录结构固定成:
dataset/ ├── hello/ │ ├── 001.mp4 │ ├── 002.mp4 ├── thanks/ │ ├── 001.mp4 └── help/ ├── 001.mp4每个类别至少 30 段、每段 2~3 秒,同一个人不同角度、不同光照各拍几段。别小看光照,手语识别模型对背景亮度变化非常敏感,这是血泪经验。
2.2 用 OpenCV 抽帧并做手部区域裁剪
视频不能直接喂给分类模型,常见做法是抽帧 + 检测手部 + 裁剪成固定尺寸。下面这段脚本负责把 mp4 转成 224×224 的图片序列:
import cv2 import os import mediapipe as mp mp_hands = mp.solutions.hands hands = mp_hands.Hands( static_image_mode=False, max_num_hands=1, # 孤立词手语通常单手,双手词可改 2 min_detection_confidence=0.5, min_tracking_confidence=0.5 ) def extract_frames(video_path, out_dir, step=3, size=224): os.makedirs(out_dir, exist_ok=True) cap = cv2.VideoCapture(video_path) idx, saved = 0, 0 while True: ret, frame = cap.read() if not ret: break if idx % step == 0: # 每 3 帧取 1 帧,降冗余 rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) res = hands.process(rgb) if res.multi_hand_landmarks: h, w, _ = frame.shape xs = [lm.x for lm in res.multi_hand_landmarks[0].landmark] ys = [lm.y for lm in res.multi_hand_landmarks[0].landmark] x1, x2 = int(min(xs)*w), int(max(xs)*w) y1, y2 = int(min(ys)*h), int(max(ys)*h) pad = 30 # 外扩像素,防止手势贴边被切 x1, y1 = max(0, x1-pad), max(0, y1-pad) x2, y2 = min(w, x2+pad), min(h, y2+pad) crop = frame[y1:y2, x1:x2] if crop.size == 0: idx += 1 continue crop = cv2.resize(crop, (size, size)) cv2.imwrite(os.path.join(out_dir, f"{saved:04d}.jpg"), crop) saved += 1 idx += 1 cap.release()逻辑说明:step=3是抽帧间隔,视频 30fps 时相当于每秒留 10 帧,既保留动作变化又不会让数据爆炸。max_num_hands=1对应孤立词单手场景,如果你做的是双手词,改成 2 并分别裁剪。pad=30是外扩,防止手部关键点刚好在边缘导致裁剪区域过小。min_detection_confidence调到 0.5 是平衡漏检和误检,光线差的环境可以降到 0.3,但误检会变多。
2.3 数据增强与划分,别让模型记住背景
抽完帧后,按 8:1:1 划分训练/验证/测试。增强只对训练集做,常见组合是随机旋转 ±15°、随机亮度 ±20%、随机水平翻转。注意:水平翻转对手语要谨慎,有些手势左右手含义不同,翻完标签就错了。我一般只对明确对称的词做翻转。
from torchvision import transforms train_tf = transforms.Compose([ transforms.RandomRotation(15), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])Normalize用的 ImageNet 均值方差,因为后面用迁移学习。如果你从零训,可以换成自己数据集的统计值,但迁移学习下这套参数最稳。
3. 模型选型与训练:MobileNetV3 为什么比 ResNet50 更适合课设
3.1 选型理由:精度够用、推理快、显存友好
手语孤立词分类本质是图像分类。ResNet50 精度高但参数量 25M+,课设机器(甚至笔记本 CPU)推理一帧要几百毫秒,前端体验直接崩。MobileNetV3-Small 参数量约 2.5M,CPU 单帧 20~40ms,精度在 10~20 类手语上通常只比 ResNet50 低 1~3 个百分点。我一般选 MobileNetV3-Small 做基线,如果精度不够再换 MobileNetV3-Large 或 EfficientNet-B0。
| 模型 | 参数量 | CPU 单帧推理 | 适合场景 |
|---|---|---|---|
| MobileNetV3-Small | ~2.5M | 20~40ms | 课设、实时前端 |
| MobileNetV3-Large | ~5.4M | 50~80ms | 精度优先 |
| ResNet50 | ~25M | 200ms+ | 服务器端离线 |
3.2 训练脚本与关键参数
import torch import torch.nn as nn from torchvision import models, datasets, transforms from torch.utils.data import DataLoader device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = models.mobilenet_v3_small(weights=models.MobileNet_V3_Small_Weights.DEFAULT) model.classifier[3] = nn.Linear(model.classifier[3].in_features, 10) # 10 类 model = model.to(device) train_ds = datasets.ImageFolder("dataset/train", transform=train_tf) val_ds = datasets.ImageFolder("dataset/val", transform=val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) # 缓解过拟合 optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20) for epoch in range(20): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() loss = criterion(model(imgs), labels) loss.backward() optimizer.step() scheduler.step() # 每个 epoch 后在 val_loader 上算准确率,保存最优权重 torch.save(model.state_dict(), "best_handsign.pth")参数说明:batch_size=32在 8G 显存下安全,显存小改 16。lr=3e-4是迁移学习常用起点,太大容易把预训练权重冲烂。label_smoothing=0.1对小手语数据集很关键,能明显压住过拟合。T_max=20和总 epoch 对齐,余弦退火让后期学习率平滑降到接近 0。
3.3 训练过程要看什么指标
别只盯准确率。手语数据集类别不平衡时,准确率会骗人。我一般同时看混淆矩阵和每类 F1。如果某一类总是被误判成另一类,大概率是这两个手势本身太像,或者采集时背景没换。解决办法:补采这两类的困难样本,或者把这两个词合并成一个粗标签。
4. 后端服务与数据库:Flask + MySQL 怎么接住模型输出
4.1 接口设计:一个上传接口 + 一个查询接口
后端用 Flask 起两个核心接口:/api/recognize接收图片或视频帧,返回识别标签和置信度;/api/history返回历史记录。模型在服务启动时加载一次,不要每次请求都 load,否则延迟爆炸。
from flask import Flask, request, jsonify import torch, io from PIL import Image import pymysql app = Flask(__name__) model = models.mobilenet_v3_small() model.classifier[3] = nn.Linear(model.classifier[3].in_features, 10) model.load_state_dict(torch.load("best_handsign.pth", map_location="cpu")) model.eval() LABELS = ["hello", "thanks", "help", "yes", "no", "please", "sorry", "good", "bad", "ok"] def db(): return pymysql.connect(host="localhost", user="root", password="your_pwd", database="sign_db", charset="utf8mb4") @app.route("/api/recognize", methods=["POST"]) def recognize(): file = request.files["frame"] img = Image.open(io.BytesIO(file.read())).convert("RGB") x = val_tf(img).unsqueeze(0) with torch.no_grad(): prob = torch.softmax(model(x), dim=1)[0] idx = int(prob.argmax()) label, conf = LABELS[idx], float(prob[idx]) conn = db() with conn.cursor() as cur: cur.execute( "INSERT INTO records(label, confidence, created_at) " "VALUES (%s, %s, NOW())", (label, conf)) conn.commit(); conn.close() return jsonify({"label": label, "confidence": round(conf, 4)})逻辑说明:model.eval()必须加,否则 BatchNorm 和 Dropout 会按训练模式跑,结果随机。torch.no_grad()省显存和计算。置信度低于 0.6 时我一般返回「不确定」,而不是硬报一个标签,前端体验会好很多。
4.2 建表语句与索引
CREATE TABLE records ( id INT AUTO_INCREMENT PRIMARY KEY, label VARCHAR(32) NOT NULL, confidence FLOAT NOT NULL, created_at DATETIME NOT NULL, INDEX idx_created (created_at), INDEX idx_label (label) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;idx_created让前端按时间倒序查历史时不用全表扫。idx_label用于按标签筛选。数据量上万后这两个索引差别很明显。
4.3 前端 JavaScript 调用与摄像头采集
前端用getUserMedia抓帧,定时 POST 到后端。注意别每帧都发,2~3 秒发一次就够,否则后端压力大且识别结果抖动。
const video = document.getElementById("cam"); navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } }) .then(stream => { video.srcObject = stream; video.play(); }); setInterval(async () => { const canvas = document.createElement("canvas"); canvas.width = 224; canvas.height = 224; canvas.getContext("2d").drawImage(video, 0, 0, 224, 224); canvas.toBlob(async blob => { const fd = new FormData(); fd.append("frame", blob, "frame.jpg"); const res = await fetch("/api/recognize", { method: "POST", body: fd }); const data = await res.json(); document.getElementById("result").innerText = `${data.label} (${data.confidence})`; }, "image/jpeg", 0.8); }, 2000);toBlob的 0.8 是 JPEG 质量,太高传输慢,太低模型看不清。setInterval2000ms 是识别节奏,太快结果会跳。
5. 避坑与排查:这 5 个问题几乎每个人都会遇到
5.1 现象:训练准确率 99%,一开摄像头就乱报
原因:数据集背景太单一,模型学到了背景而不是手势。解决:采集时换 3 种以上背景,训练时加随机裁剪和颜色抖动,验证集必须来自不同背景。
5.2 现象:OpenCV 读中文路径视频返回 None
原因:cv2.VideoCapture在部分平台对非 ASCII 路径支持差。解决:路径全用英文,或者用cv2.imdecode配合np.fromfile读。
5.3 现象:Flask 接口第一次请求特别慢,后面正常
原因:模型懒加载或每次请求重新 load。解决:在app.run之前全局加载模型,用model.eval()固定推理模式。
5.4 现象:MySQL 插入中文标签变成问号
原因:连接字符集不是 utf8mb4。解决:pymysql.connect加charset="utf8mb4",建表也用 utf8mb4。
5.5 现象:前端识别结果疯狂闪烁
原因:每帧都发请求,模型对相似帧输出不稳定。解决:加滑动窗口投票,连续 3 次结果一致才更新 UI,或者降低发送频率到 2 秒一次。
6. 进阶技巧:把孤立词串成短句的轻量做法
孤立词识别跑通后,很多人会想再往前走一步:能不能识别「你好 + 谢谢」这种两词短句?完整连续手语翻译需要 CTC 或 Transformer 序列模型,课设周期内不现实。我一般用一个轻量状态机做过渡:维护一个长度为 5 的结果队列,当检测到某个标签连续出现 3 次且与上一个稳定标签不同,就把它追加到句子缓冲区,超过 5 秒没有新词就截断成一句。
from collections import deque, Counter class SentenceBuilder: def __init__(self, window=5, stable=3, timeout=5.0): self.buf = deque(maxlen=window) self.stable = stable self.timeout = timeout self.last_label = None self.last_time = 0 self.sentence = [] def push(self, label, conf, now): if conf < 0.6: return self.buf.append(label) if len(self.buf) < self.stable: return top, cnt = Counter(self.buf).most_common(1)[0] if cnt >= self.stable and top != self.last_label: if now - self.last_time > self.timeout: self.sentence = [] # 超时开新句 self.sentence.append(top) self.last_label = top self.last_time = nowwindow=5是投票窗口,stable=3是判定稳定所需次数,timeout=5.0是句子截断秒数。这套逻辑不完美,但比直接拼接稳定得多。验证方法是:录 20 段两词组合视频,看句子缓冲区输出和人工标注的一致率,一般能到 70% 以上就算可用。
我自己的习惯是,任何识别类项目先把「单帧准确率」和「连续输出稳定性」分开测,前者看模型,后者看后处理。很多人模型训得不错,却因为没做投票和超时,演示时句子乱跳,最后被当成模型不行。希望帮到你。
本文还有配套的精品资源,点击获取