简介:一套基于图像的手语识别系统完整项目,聚焦人体动作识别方向,面向计算机专业毕业设计、课程设计及期末大作业场景。项目包含25个Python源文件,以及UI界面文件、YOLO模型配置、训练好的pkl模型和文档说明,覆盖数据预处理、关键帧提取、特征提取、模型训练、预测与可视化等完整流程,方便理解从图像输入到动作分类的落地实现。压缩包共43个文件,大小仅1.47MB,结构清晰,代码与文档分层放置,便于快速运行和二次开发。该项目由作者大四期间完成,经导师指导并高分通过评审(99分),代码完整可运行,对正在准备毕设或入门人体动作识别的学习者具有较高参考价值。目前已有160人学习下载,适合需要完整项目源码和实现思路作为参考的读者。
1. 手语识别不是图像分类:为什么选择「检测+姿态+贝叶斯」三步走架构
把一段手语视频逐帧丢给图像分类网络,十个里有九个准确率崩掉。这不是网络不够深,而是单帧手语识别面对的首要问题不是"这个手势叫什么",而是"手在哪里、手指关节怎么排布"。背景墙、衣袖、肤色相近的物体都会让端到端分类模型学到错误的相关性。这个项目给出的解法和工业界做姿态估计的思路一致:先用目标检测框出手部区域,再用关键点提取把手的结构数值化,最后拿这些结构化特征去训练一个轻量分类器。整个链路在 CPU 上就能跑通,无需 GPU,对刚接触动作识别的学生和需要快速验证算法的工程师都比较友好。下面按模块拆解这条链路的核心实现和参数细节。
2. YOLOv3手部检测与Graphviz结构化输出:从yolo_video.py到SaveImg_graphviz.py
2.1 检测模块的选型理由与文件分工
手语识别的第一步是确定手部位置。项目里用的是 YOLOv3,原因很直接:在 CPU 推理场景下,YOLOv3 的 Darknet-53 主干可以通过调整输入尺寸和置信度阈值换取实时性,而它的多尺度预测对手部这种中等尺寸目标比较稳。如果你用过 Faster R-CNN 就会知道,它精度高但依赖区域提议网络,单帧 CPU 推理时间通常在秒级;YOLOv3 把检测当成回归问题一次出框,速度优势明显。
检测相关的代码分布在几个文件里:yolo3/model.py定义网络结构,yolo.py封装模型加载和推理,yolo_video.py负责处理视频输入,SaveImg_graphviz.py则把检测结果绘制成图结构。model.py里是标准的 Darknet-53 卷积块和残差连接,建议不要改动网络层数,只调整输入尺寸和锚点。yolo.py里的YOLO类初始化时读权重文件和类别文件,类别文件里只需要一类:hand。
2.2 推理主流程与参数配置
视频推理入口yolo_video.py的核心循环大致是:读帧、缩放、进网络、解析输出、画框、写视频。这里的detect_image方法是一个可以被单独调用的函数,如果你想让检测直接应用到单张图像,也可以绕过视频循环,只调用yolo.py中封装的检测接口。代码示意如下:
# yolo_video.py 中视频循环的简化框架 import cv2 from yolo import YOLO yolo = YOLO(model_path='model_data/yolo_weights.h5', anchors_path='model_data/yolo_anchors.txt', classes_path='model_data/hand_classes.txt', score=0.25, iou=0.45) cap = cv2.VideoCapture('input_video.mp4') while True: ret, frame = cap.read() if not ret: break # 检测接口返回 (框坐标, 类别, 置信度) image, boxes, scores, classes = yolo.detect_image(frame) # 后续把 boxes 送入特征提取或直接可视化score=0.25表示置信度低于 0.25 的框会被丢弃,iou=0.45是 NMS 的 IoU 阈值。这两个参数直接决定手部框召回率和误检率的平衡。手部区域可以被衣服颜色干扰时,我会把score提到 0.3;如果检测不到手,反而把它降到 0.15。detect_image返回的boxes是相对原图的坐标,这一点在后续特征提取时要注意,因为姿态估计模块可能在不同尺寸的图上工作。
2.3 Graphviz输出用于评估检测质量
SaveImg_graphviz.py是这套代码里不太起眼但很实用的工具。它把检测出的手部框以 Graphviz 的有向图节点形式输出,每个节点记录框坐标、宽高和置信度。这样做的价值在于:当你批量跑完一个视频后,不用一张张翻图像,直接看生成的.png图就能发现哪些帧把背景误检成了手,哪些帧因为手部过小导致框的宽高比异常。示意图结构如下:
# SaveImg_graphviz.py 的核心建图逻辑 from graphviz import Digraph dot = Digraph(comment='hand_detection') for idx, (x1, y1, x2, y2, score) in enumerate(boxes): label = f'frame_{idx}\\nx1={x1} y1={y1}\\nw={x2-x1} h={y2-y1}\\nscore={score:.2f}' dot.node(str(idx), label, shape='box') if idx > 0: dot.edge(str(idx-1), str(idx)) # 输出为图片文件 dot.render('output_graph', format='png', cleanup=True)这段代码把每一帧的检测框转换成图节点,节点之间用边连接表示时间顺序。cleanup=True表示只保留渲染后的 PNG,不保留中间.dot源文件。通过观察框的宽高比和置信度变化趋势,可以定位哪些手势在运动模糊下检测不稳定,从而决定是否在检测前引入帧间平滑。
2.4 常见坑:小目标漏检与多尺度锚点
用 YOLOv3 检手,最常遇到的坑有两个。第一个是手部目标太小,特别是视频里人物距离镜头远时,手可能只有 32x32 像素,此时 13x13 的预测层几乎不可能召回这个目标,要靠 52x52 的浅层特征。因此输入尺寸不建议用 320x320,最好保持 416x416 或更高。第二个是锚点不匹配导致的漏检。model_data/yolo_anchors.txt里默认锚点是 COCO 80 类数据集聚类出来的,手部的形状比例和完整人体不同。我一般会把自己数据集中标注好的手部框宽高提取出来,用 K-Means 重新聚类 9 组锚点,替换掉原文件,召回率通常能提高 5 到 8 个百分点。
3. 姿态估计与手部特征构建:coco.py关键点提取与hand_fD.py特征设计
3.1 COCO关键点索引与手部语义的对应
检测框只是第一步,要区分不同手势,需要把框内的手部姿态数值化。项目用的是 COCO 骨骼点格式,即 OpenPose 输出的 18 个关键点布局。其中和手部直接相关的是索引 4(左手腕)、7(右手腕),以及通过pose_hand.py扩展出的手部关键点。COCO 的原始关键点定义里没有手指关节,所以hand_fD.py的作用是补充手指信息的空缺。常见做法是用 MediaPipe 或 OpenPose 的 hand 模型在检测框内再做一次关键点回归,得到每根手指的 4 个关键点,共 21 点。它们的索引对应关系如下表:
| 索引范围 | 语义 | 在手语识别中的作用 |
|---|---|---|
| 0 | 手腕根部 | 特征计算的原点 |
| 1-4 | 拇指(掌骨到指尖) | 区分拇指是否内扣 |
| 5-8 | 食指 | 指向类手势的关键 |
| 9-12 | 中指 | 配合食指做弯曲判断 |
| 13-16 | 无名指 | 部分手语的次要特征 |
| 17-20 | 小指 | 区分数字类手势 |
3.2 关键点坐标解析与置信度过滤
coco.py在代码里承担了把网络输出的热力图变成坐标点的工作。姿态估计网络通常输出(batch, 21, 64, 64)的热力图,每个通道对应一个关键点,需要取峰值位置再映射回原图尺寸。下面是一个典型的关键点提取函数:
# coco.py 中从热力图解析关键点坐标 import numpy as np def get_keypoints(heatmaps, orig_w, orig_h): # heatmaps: (21, 64, 64) keypoints = [] for idx in range(heatmaps.shape[0]): hmap = heatmaps[idx] # 找到响应最大的位置作为关键点 y, x = np.unravel_index(np.argmax(hmap), hmap.shape) # 把 64x64 坐标映射回原图尺寸 x_orig = int(x / hmap.shape[1] * orig_w) y_orig = int(y / hmap.shape[0] * orig_h) # 最大响应值低于阈值时标记为不可靠 score = hmap[y, x] keypoints.append((x_orig, y_orig, score)) return keypoints代码里的score是关键点的置信度。处理手语视频时,手指相互遮挡会导致指尖关键点的置信度低于 0.1。我的处理策略是:当一个关键点置信度低于 0.3 时,不直接丢弃样本,而是用上一帧的坐标做线性插值补齐;如果连续 5 帧都检测不到,则该样本标记为缺失,不进入特征矩阵。因为贝叶斯分类器对缺失值的处理能力有限。
3.3 构造旋转、尺度无关的手部特征向量
有了 21 个关键点坐标,接下来要设计特征向量。最朴素的特征是直接把(x, y)坐标拼接成 42 维向量,但这样做有两个问题:一是手部在画面中的绝对位置会干扰分类,二是手距离摄像头远近不同导致的特征尺度差异很大。hand_fD.py的解决方案是:以手腕关键点(索引 0)为原点,计算其他 20 个关键点相对手腕的归一化角度和长度比。
常见的做法是把每个关键点与手腕的连线拆成两个描述子:距离比值和角度。距离除以手掌基准长度消除尺度影响,角度取arctan2得到 0 到 2π 的值,再统一规范到 0 到 1。加上每个关键点的原始置信度,最终特征维度是20 * 2 + 21 = 61维。代码如下:
# hand_fD.py 特征构造示意 def build_hand_features(kpts, palm_base_len=1.0): wrist_x, wrist_y, _ = kpts[0] feats = [] angle_list = [] for kpt in kpts[1:]: x, y, conf = kpt dx, dy = x - wrist_x, y - wrist_y dist = np.sqrt(dx**2 + dy**2) / palm_base_len angle = np.arctan2(dy, dx) / (2 * np.pi) # 归一化到 [0, 1) feats.extend([dist, angle]) angle_list.append(angle) # 把所有角度再按均值旋转对齐,减少手腕转动的影响 angle_mean = np.mean(angle_list) for i in range(0, len(feats), 2): feats[i+1] = (feats[i+1] - angle_mean) % 1.0 # 拼上原始置信度 feats.extend([k[2] for k in kpts[1:]]) return np.array(feats, dtype=np.float32)palm_base_len的取法很关键。我用的是手腕到中指掌骨根部的距离,也就是索引 0 到索引 9 的欧氏距离。如果直接用手部框的对角线长度,特征会受检测框大小波动影响,而这个距离相对稳定。角度对齐步骤是为了消除手腕旋转带来的偏差,让同一个手势在不同前臂转动角度下特征一致。
3.4 特征提取与数据矩阵的衔接
get_features.py把上述特征提取过程批量应用到训练数据上。它遍历所有视频帧或图像,对每一帧检测手部、提取关键点、构建特征向量,最后输出一个features.npy和一个对应的labels.npy。在我实际使用中,这个脚本最容易出问题的地方是数据不平衡:数字手势如"一、二、三"样本充足,但一些不常用的手势可能只有几十帧。后面会讲如何用贝叶斯分类器应对这种情况。
4. 贝叶斯分类与模型持久化:beyes.py、predict.py与train_model.pkl的完整链路
4.1 为什么用小样本友好的贝叶斯而不是深度分类头
特征维度 61 维、类别数几十个、每类样本少则几十帧——这是一个典型的小样本分类问题。此时在特征向量后面接一个全连接层和 Softmax 做端到端分类不是不行,但网络容易过拟合,而且调参成本高。项目里beyes.py选择朴素贝叶斯分类器,它假设特征条件独立,虽然这个假设在真实手语特征上并不严格成立,但在样本量有限时它比判别式模型更稳定,训练速度也快一个量级。train_model.pkl就是训练完成后用序列化保存的模型文件,推理阶段不再需要重新训练。
贝叶斯分类器对特征的分布假设可以手动指定。高斯朴素贝叶斯适合距离类特征,因为它们的值域是连续的正数;而角度类特征被归一化到[0, 1)区间,如果原始分布接近均匀,使用贝努利贝叶斯反而会丢失信息。beyes.py里默认采用的是高斯朴素贝叶斯,它对每个特征单独估计均值和方差,这也意味着:如果某个特征的方差接近 0,即所有样本的该特征值几乎相同,分类器会在此维度上给出极高概率,反而掩盖其他维度的判别信息。所以特征工程阶段要避免近常量的特征混入。
4.2 训练侧:数据读取、交叉验证与模型保存
训练代码的核心逻辑是加载特征矩阵,划分训练测试集,训练分类器,保存模型。完整流程如下:
# beyes.py 训练流程示意 import joblib import numpy as np from sklearn.naive_bayes import GaussianNB from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, classification_report # 加载 get_features.py 输出的特征和标签 X = np.load('features.npy') y = np.load('labels.npy') # 按类别分层划分,防止某个手势在测试集里缺失 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) clf = GaussianNB(var_smoothing=1e-9) clf.fit(X_train, y_train) print(accuracy_score(y_test, clf.predict(X_test))) # 保存模型供 predict.py 加载 joblib.dump(clf, 'train_model.pkl')var_smoothing是高斯朴素贝叶斯里最容易影响结果的一个超参数。它会在每个特征的方差上加上一个常数,防止因某个特征方差为 0 导致概率计算溢出。默认值是 1e-9,但手语特征矩阵里角度特征可能出现极端均匀分布,此时把var_smoothing调到 1e-6 更安全,代价是模型对特征差异的敏感度略微下降。stratify=y保证划分时每个类别的样本比例一致,避免小类别出现空测试集。
调试这套流程时,先看classification_report中每个手势类别的 F1-score。如果某两个手语的混淆主要集中在特定的手指弯曲特征上,说明这两个手势在特征空间里本身重叠度高,需要回到hand_fD.py增加新的距离特征,而不是盲目调参。
4.3 推理侧:predict_beyes.py加载模型与实时类别概率输出
predict.py和predict_beyes.py负责推理。它们加载train_model.pkl,对输入的图像或视频帧执行同样的特征提取,然后输出类别和置信度。这里的置信度是predict_proba的结果,代表每个类别的后验概率,和深度学习里经过 Softmax 的概率含义不同,不能直接用于跨类别的阈值比较,但可以用于同一类别内的置信度排序。
# predict_beyes.py 推理示意 import joblib import numpy as np clf = joblib.load('train_model.pkl') CLASS_NAMES = ['A', 'B', 'C', ...] # 与训练标签保持一致 def predict_hand(image, detector, hand_feat_extractor): # 1. 检测手部区域 boxes = detector.get_boxes(image) if len(boxes) == 0: return None, 0.0 # 2. 取最大面积的手部框做关键点提取 box = max(boxes, key=lambda b: (b[2]-b[0])*(b[3]-b[1])) hand_crop = image[box[1]:box[3], box[0]:box[2]] kpts = hand_feat_extractor.extract(hand_crop) # 3. 构造特征并预测 feats = build_hand_features(kpts).reshape(1, -1) proba = clf.predict_proba(feats)[0] idx = int(np.argmax(proba)) return CLASS_NAMES[idx], proba[idx]推理端最常见的错误是训练和推理的特征顺序不一致。build_hand_features的返回值顺序一旦在训练后调整过,推理时必须同步修改,否则会出现"训练时准确率 95%,推理时完全乱套"的诡异现象。所以我一般会在保存模型时同时保存一份特征构建配置,比如joblib.dump({'clf': clf, 'feat_cfg': cfg}, 'train_model.pkl'),而不是只存分类器对象。
4.4 参数边界:方差平滑与先验概率的调整
GaussianNB类里还有一个priors参数可以指定类别先验。默认情况下,它会按训练集中各类样本占比计算先验概率。当某个手语类别在数据集中样本极少时,先验概率偏低,即使某帧图像特征很接近这个类别,后验概率也会被先验拉低。解决方式有两种:一是对少数类做简单数据增强,比如把它的关键点坐标加一点高斯噪声生成多个变体;二是在GaussianNB(priors=...)里手动把先验设为均匀分布。第一种方式更符合实际,因为它同时增加了特征的多样性,而第二种只是强行拉平概率。
5. 从视频到样本的工程管线,以及手语识别的三个调优技巧
5.1 videoConv.bat与getKeyFrame.py:把视频变成干净训练集
训练数据不能直接用原始视频帧。videoConv.bat是一个批处理脚本,它批量调用 FFmpeg 把不同格式的视频统一转换成 30fps、720p 的 MP4 文件。为什么要先统一格式?因为手语视频来源不同,H.264 和 H.265 解码后的颜色空间差异会影响 YOLOv3 的检测框稳定性,统一成 H.264 后能减少这一层干扰。getKeyFrame.py负责从视频中抽取关键帧,它通过计算相邻帧的像素差,只保留手部运动位移超过阈值的帧,避免把大量静止帧送进特征提取流程。
使用中要注意resizevideos.py和resizefiles.py这类脚本的分工。前者处理视频尺寸,后者处理文件批量重命名。训练集命名建议包含手势类别标签,比如A_001.mp4、B_002.mp4,这样data_process.py扫描文件名就能自动生成标签,减少手动标注工作量。
5.2 技巧一:检测置信度阈值加动态退避
视频中手的尺度是连续变化的。手靠近镜头时检测框大、置信度高;手远离时目标小、置信度低。固定阈值 0.25 会在远端丢框,导致特征序列断裂。做法是设置一个低阈值和一个高阈值:当检测置信度低于低阈值时,认为该帧无手;当置信度处于高、低阈值之间时,用上一帧的手部框作为当前框,只更新坐标不做重新检测。这个机制对 30fps 视频效果明显,因为相邻帧手部位移有限,用上一帧框做检测区域裁剪,反而能减少背景干扰。我把两个阈值分别设为 0.1 和 0.4,实际连续检测的有效帧率提升了一成多。
5.3 技巧二:用留存手势做概率校准
predict_proba输出的概率和真实命中率之间往往有偏差。例如某手势后验概率输出 0.7,但实际测试集上该类别准确率只有 0.5。这种偏差来自特征独立性假设。校准方法是:把训练集再留出一部分,统计每个类别的平均输出概率和实际正确率,拟合一个分段线性映射函数,推理时把贝叶斯概率映射到校准后的置信度。手语识别里,我只对 top-1 概率做映射,因为第二候选类别概率通常接近随机水平,校准意义不大。完成这一步后,给UI_main.py界面里的识别结果显示提供更可靠的置信度参考。
5.4 技巧三:按视频划分而非按帧划分数据
train_test_split默认按帧随机划分,会导致同一个视频的相邻帧同时出现在训练集和测试集。这些帧之间的特征高度相似,测试准确率虚高。正确做法是把视频文件作为最小单位,按文件名分组划分。
# 按视频文件名分组后划分训练/测试集 from sklearn.model_selection import GroupShuffleSplit video_ids = get_video_ids(labels) # 每个样本对应的视频ID splitter = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(splitter.split(X, y, groups=video_ids))group参数必须是每个样本所属视频的唯一标识,这样才能防止同源帧泄漏。做完这三个调整后,再回到predict_beyes.py验证单帧识别结果,观察不同手势在真实视频上的混淆矩阵,一般训练集准确率和实测准确率的差距能从一个不合理的数值收窄到十个百分点以内。
本文还有配套的精品资源,点击获取