尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

手语图像分类数据集实战:2500张样本从数据清洗到CNN baseline

发布时间:2026/9/28 23:44:04

资讯中心
01
ARTICLE

手语图像分类数据集实战:2500张样本从数据清洗到CNN baseline

手语图像分类数据集实战:2500张样本从数据清洗到CNN baseline
简介这份手语图像分类数据集面向计算机视觉入门与进阶学习者、深度学习课程实验者以及需要手势识别素材的开发者提供约2500张已标注的手语图像覆盖0、1、a、b等36个类别具体类别可查阅包内json文件。资源已按训练集与测试集划分同类图片分别存放便于直接接入CNN分类流程同时附带show脚本可快速可视化样本分布与图像内容降低数据检查成本。压缩包共约2000个文件以1998张jpeg图像为主体另含1个py脚本与1个json标注文件整体约28.58MB体积轻便适合本地快速加载与实验。目前已有442人学习下载可作为手语识别、图像分类课程设计或网络改进实验的起点帮助读者省去数据采集与标注环节把精力集中在模型搭建、训练调参与效果对比上。1. 手语图像分类数据集2500 张已标注样本能跑出什么结果上周有个做无障碍交互的读者发来一张截图说他用公开渠道凑了 800 多张手语手势图训练集准确率冲到 0.96测试集却掉到 0.41问我是不是网络结构选错了。我让他把数据分布打出来一看36 个类别里有 11 个类别样本数不到 15 张测试集里还混进了训练集同源的连拍帧。这不是模型的问题是数据集的问题。手语图像分类这件事卡脖子的从来不是 CNN 结构而是类别均衡、标注一致性和训练测试划分这三件事。今天拆的这份手语图像分类数据集约 2500 张已标注样本覆盖 36 个类别0、1、a、b 等具体以包内 json 为准已经预先划分好训练集和测试集同类图片归入同一目录还附带一个可视化 show 脚本。它适合三类人想入门图像分类但不想在爬数据上耗两周的新手、需要一份干净 baseline 做对照实验的算法工程师、以及做手语识别 demo 想快速验证链路的产品侧同学。下面我按「这份数据长什么样 → 怎么读进来 → 怎么训 → 坑在哪 → 怎么榨干它」的顺序把能复现的细节全摊开。2. 数据集结构与标注格式先看清目录再动手2.1 目录组织与文件命名规律拿到压缩包解压后第一件事不是急着写训练脚本而是把目录树打出来。这份数据的组织方式比较典型根目录下按划分拆成训练和测试两个大目录每个大目录里再按类别分子目录图片直接躺在类别目录下。从项目正文给出的文件名样本可以反推出命名规则比如hand5_g_bot_seg_3_cropped.jpeg、hand1_j_right_seg_4_cropped.jpeg、hand4_n_bot_seg_4_cropped.jpeg拆开看是「hand 数字 下划线 字母 下划线 位置 下划线 seg 下划线 序号 cropped」的结构。这里的数字和字母组合对应类别标签bot、right这类词描述手部在画面中的方位seg表示经过分割处理cropped说明已经裁剪到手势主体区域。理解命名规律的价值在于当 json 标签文件和目录名对不上时你能靠文件名反查类别不至于抓瞎。先用一段脚本把目录结构和类别分布摸清楚这是后面所有操作的基准。import os import json from collections import Counter DATA_ROOT ./sign_language_dataset # 换成你解压后的实际路径 def scan_split(split_name): split_dir os.path.join(DATA_ROOT, split_name) if not os.path.isdir(split_dir): print(f[跳过] 未找到目录: {split_dir}) return class_dirs sorted([d for d in os.listdir(split_dir) if os.path.isdir(os.path.join(split_dir, d))]) total 0 counter Counter() for cls in class_dirs: cls_path os.path.join(split_dir, cls) imgs [f for f in os.listdir(cls_path) if f.lower().endswith((.jpg, .jpeg, .png))] counter[cls] len(imgs) total len(imgs) print(f {split_name} 共 {len(class_dirs)} 类, {total} 张 ) for cls, n in counter.most_common(): print(f {cls:6}: {n}) return counter train_counter scan_split(train) test_counter scan_split(test)这段脚本做三件事遍历指定划分下的所有类别目录、统计每类图片数量、按数量降序打印。DATA_ROOT要改成你本地解压路径scan_split对 train 和 test 各跑一次。跑完你会得到一张类别分布表这张表决定了后面要不要做重采样。如果发现某类只有个位数样本而另一类有上百张那训练时就得考虑加权损失或者过采样否则模型会偏向多数类。2.2 json 标签文件怎么读、字段怎么对应摘要里明确提到「分类个数 36具体查看 json 文件」说明类别映射关系不在目录名里硬编码而是存在一个 json 里。常见做法是 json 维护一个class_to_idx或idx_to_class的字典训练时用 ImageFolder 或自定义 Dataset 读取。先把这个 json 加载出来确认映射别等到训练完发现标签错位。import json with open(os.path.join(DATA_ROOT, classes.json), r, encodingutf-8) as f: class_map json.load(f) # 兼容两种常见结构{0: a} 或 {a: 0} if all(isinstance(v, str) for v in class_map.values()): idx_to_class {int(k): v for k, v in class_map.items()} class_to_idx {v: int(k) for k, v in class_map.items()} else: class_to_idx {k: int(v) for k, v in class_map.items()} idx_to_class {int(v): k for k, v in class_map.items()} print(f类别总数: {len(class_to_idx)}) print(前 10 个映射:, list(class_to_idx.items())[:10])这里做了个兼容处理因为不同人导出 json 的习惯不一样有的用类别名做 key、索引做 value有的反过来。idx_to_class和class_to_idx两个方向都建好后面做可视化时要用索引反查类别名。如果 json 里字段名不是class_to_idx而是别的比如labels或categories把 key 换掉即可。确认映射无误后再和目录名做一次交叉校验目录里出现的类别名应该和 json 的 value 集合完全一致多一个少一个都要查。2.3 可视化 show 脚本的用法与改造资源里带了 show 脚本这是快速建立数据直觉的入口。一般这类脚本会随机采样若干张图拼成网格显示或者按类别各取一张。直接跑之前先看一眼它依赖什么常见是 matplotlib PIL。如果脚本里写死了路径改一下再跑。# 先看脚本头部依赖和路径配置 head -30 show.py # 确认依赖装齐 pip install matplotlib pillow numpy # 运行 python show.py如果 show 脚本只做了随机展示我一般会改成按类别采样这样能一眼看出类间差异和类内差异。改造思路是对每个类别目录取前 N 张拼成类别数 × N的网格每张图上方标注类别名。这样跑一次就能发现哪些类别长得像、哪些类别内部姿态差异大。可视化不是走过场它直接告诉你这个任务的难度上限在哪——如果两个类别肉眼都难分模型分错就不冤。3. 从零跑通一个 CNN 分类 baseline3.1 数据加载与增强策略怎么定数据加载这块最省事的是torchvision.datasets.ImageFolder前提是目录结构符合「根目录/类别名/图片」的格式。这份数据正好符合所以可以直接用。但要注意ImageFolder 是按目录名的字母顺序分配索引的而 json 里的索引可能是另一套顺序两者必须对齐。稳妥做法是自定义 Dataset显式用class_to_idx映射。import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image class SignDataset(Dataset): def __init__(self, root, split, class_to_idx, transformNone): self.samples [] self.class_to_idx class_to_idx self.transform transform split_dir os.path.join(root, split) for cls_name in os.listdir(split_dir): cls_dir os.path.join(split_dir, cls_name) if not os.path.isdir(cls_dir): continue if cls_name not in class_to_idx: print(f[警告] 目录 {cls_name} 不在 json 映射中跳过) continue label class_to_idx[cls_name] for fname in os.listdir(cls_dir): if fname.lower().endswith((.jpg, .jpeg, .png)): self.samples.append( (os.path.join(cls_dir, fname), label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(RGB) if self.transform: img self.transform(img) return img, label # 训练增强随机裁剪 翻转 颜色抖动 train_tf transforms.Compose([ transforms.Resize((128, 128)), transforms.RandomResizedCrop(112, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) # 测试增强只做 resize 和归一化 test_tf transforms.Compose([ transforms.Resize((112, 112)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])自定义 Dataset 的好处是标签映射完全可控class_to_idx从 json 来目录名对不上会打警告而不是静默错位。增强策略上手语图像的关键信息在手指形状和相对位置所以水平翻转要谨慎——有些手势翻转后语义会变比如左右手对称的手势。我一般先不开翻转跑一版看混淆矩阵里有没有明显的左右混淆再决定要不要加。颜色抖动可以开因为手语识别不该依赖肤色和光照。归一化用的是 ImageNet 的均值和方差这是迁移学习的常规操作即使从零训也建议保持一致方便后面换预训练权重。3.2 一个够用的 CNN 结构与训练循环baseline 不需要上 ResNet一个 4 层卷积的小网络就能看出数据本身的天花板。结构设计上每两个卷积后接一次池化最后全局平均池化接全连接。这样参数量小训练快过拟合风险低。import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes36): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.Conv2d(32, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 112 - 56 nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.Conv2d(64, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 56 - 28 nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), # 28 - 14 nn.AdaptiveAvgPool2d(1), # 14 - 1 ) self.classifier nn.Linear(128, num_classes) def forward(self, x): x self.features(x) x x.flatten(1) return self.classifier(x) device torch.device(cuda if torch.cuda.is_available() else cpu) model SmallCNN(num_classeslen(class_to_idx)).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30)训练循环里有两个细节值得说。一是每个 epoch 后在测试集上算准确率但不要用测试集调超参测试集只在最后看一次中间用训练集切一小块做验证。二是记录每类的准确率光看总体准确率会掩盖小类被牺牲的问题。def evaluate(model, loader): model.eval() correct, total 0, 0 with torch.no_grad(): for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) preds model(imgs).argmax(1) correct (preds labels).sum().item() total labels.size(0) return correct / total train_ds SignDataset(DATA_ROOT, train, class_to_idx, train_tf) test_ds SignDataset(DATA_ROOT, test, class_to_idx, test_tf) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers2) test_loader DataLoader(test_ds, batch_size64, shuffleFalse, num_workers2) for epoch in range(30): model.train() running_loss 0.0 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() running_loss loss.item() scheduler.step() acc evaluate(model, test_loader) print(fepoch {epoch1:02d} | loss {running_loss/len(train_loader):.4f} | test_acc {acc:.4f})batch_size设 32 是因为 2500 张数据量不大太大 batch 会让一个 epoch 的更新次数太少。AdamW配weight_decay1e-4是抗过拟合的常规组合。余弦退火让学习率从 1e-3 平滑降到接近 0比固定学习率更容易收敛到好的点。如果跑完 30 个 epoch 测试准确率还在涨就加到 50如果 10 个 epoch 就平了说明数据量撑不起更深的训练该考虑迁移学习了。3.3 迁移学习怎么接换掉分类头就行小数据集的正确打开方式是迁移学习。拿一个在 ImageNet 上预训练过的 ResNet18 或 MobileNetV3把最后的全连接层换成 36 类输出前面的卷积层冻结或者用很小的学习率微调。这样即使只有 2500 张图也能比从零训高出十几个点。from torchvision import models def build_transfer_model(num_classes36, freeze_backboneTrue): model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) if freeze_backbone: for param in model.parameters(): param.requires_grad False in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) return model transfer_model build_transfer_model(len(class_to_idx)).to(device) # 只优化分类头 optimizer torch.optim.AdamW( filter(lambda p: p.requires_grad, transfer_model.parameters()), lr1e-3, weight_decay1e-4)冻结骨干时只更新分类头学习率可以给大一点。跑几个 epoch 后如果准确率停滞再解冻最后两个 stage用 1e-4 的小学习率微调。注意输入尺寸要匹配预训练模型的要求ResNet18 用 224 输入前面 transform 里的 Resize 要相应改。迁移学习的坑在于归一化参数必须和预训练时一致用错均值方差会让预训练权重白费。4. 训练前必须排查的五个坑4.1 训练集和测试集同源连拍导致虚高现象训练准确率 0.99测试准确率 0.95但拿手机拍一张真实手势图进去预测结果离谱。原因这份数据是从连续视频帧里抽的训练集和测试集里可能存在同一段手势的相邻帧两帧差异极小模型记住了背景而不是手势。解决按手势实例而不是按帧划分同一段视频的帧只能进训练集或测试集之一。如果文件名里的seg_序号是连拍序号就按hand类别方位分组整组划到一边。4.2 类别索引与 json 映射错位现象训练 loss 正常下降但混淆矩阵里所有预测都偏向某几个类且这几个类的索引恰好是字母序靠前的。原因ImageFolder 按目录名字母序分配 0 到 35而 json 里的索引是另一套顺序两者没对齐。解决用自定义 Dataset 显式读class_to_idx或者在训练前打印train_ds.class_to_idx和 json 对比不一致就重建映射。这个坑最隐蔽因为 loss 曲线看起来完全正常。4.3 图像通道与尺寸不统一现象DataLoader 报错expected 3 channels but got 1或者训练时某些 batch 形状对不上。原因部分图片是灰度图或带 alpha 通道的 PNGconvert(RGB)漏写或者写在 transform 之后。解决在__getitem__里第一时间Image.open(path).convert(RGB)确保进 transform 之前就是三通道。尺寸不统一的问题靠Resize兜底但要注意长宽比直接 Resize 到正方形会拉伸手势改用Resize加CenterCrop或者保持长宽比的 padding。4.4 小类样本被多数类淹没现象总体准确率 0.9但某些类别召回率接近 0。原因36 类里样本数差异大交叉熵损失被多数类主导。解决用WeightedRandomSampler做重采样或者给 CrossEntropyLoss 传weight参数权重取类别频率的倒数。重采样更简单改 DataLoader 的 sampler 即可但要注意重采样后一个 epoch 的样本数会变学习率调度要相应调整。4.5 测试集被反复用来调参现象测试准确率调得很高换一批新数据就崩。原因把测试集当验证集用超参、增强策略、模型选择都看着测试集调测试集信息泄漏。解决从训练集里切 10% 到 15% 做验证集所有调参看验证集测试集只在最终评估时跑一次。这份数据已经分好了 train 和 test但 train 内部还可以再切别偷懒。5. 把 2500 张数据榨出更多信息进阶技巧与验证方法数据量固定的时候提升空间在「怎么用」而不是「用多少」。第一个技巧是交叉验证把训练集切成 5 折每折训一个模型最后 ensemble 预测。2500 张做 5 折每折训练集 2000 张左右虽然单模型弱一点但 5 个模型投票能补回来而且能看出模型在不同数据子集上的稳定性。第二个技巧是测试时增强TTA对测试图做几种变换原图、水平翻转、轻微缩放每个变换各预测一次取平均概率。手语图像里翻转要慎用但如果你的类别本身左右对称TTA 能涨一两个点。验证方法上别只看总体准确率。跑一个混淆矩阵看哪些类互相混淆。如果发现a和e混淆严重就去可视化这两个类的样本大概率是手指角度接近。这时候可以针对性加数据增强比如小角度旋转或者干脆把这两个类合并再分二级分类。另一个验证手段是拿模型在训练集上的预测置信度分布如果大量样本置信度在 0.5 附近说明模型没学到判别性特征该换更强的骨干或者加数据。import numpy as np from sklearn.metrics import confusion_matrix, classification_report def full_eval(model, loader, idx_to_class): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for imgs, labels in loader: imgs imgs.to(device) preds model(imgs).argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) # 找出混淆最严重的类别对 cm_off cm.copy() np.fill_diagonal(cm_off, 0) top_pairs np.dstack(np.unravel_index( np.argsort(cm_off.ravel())[::-1][:5], cm.shape))[0] for i, j in top_pairs: print(f真实 {idx_to_class[i]} 被预测为 {idx_to_class[j]}: {cm_off[i, j]} 次) print(classification_report(all_labels, all_preds, target_names[idx_to_class[i] for i in range(len(idx_to_class))]))这段代码把混淆矩阵里非对角线的最大值挑出来直接告诉你哪两个类最容易混。classification_report给出每类的精确率、召回率和 F1小类的 F1 如果明显低于大类就回到 4.4 去处理类别不均衡。我自己的习惯是每次训完模型先跑这个评估把 top5 混淆对记下来下一轮针对性做增强或者补数据。从那以后我每次拿到新数据集都强制先跑一遍类别分布和混淆矩阵再决定网络怎么搭——数据告诉你的信息比拍脑袋选结构靠谱得多。希望这份拆解帮到你少走几个我踩过的弯路。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。