ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

1100张手语图像分类数据集:数据清点、训练与避坑全记录

1100张手语图像分类数据集:数据清点、训练与避坑全记录 简介面向图像分类入门与手语识别场景这份已标注数据集包含bad、good、friend等11个类别词汇适合用来练习CNN分类模型的训练、验证与可视化。包体约23.62MB共1108个文件主体为1106张jpg样本json文件记录类别配置py脚本用于快速展示样本训练集与测试集已划分同类图片单独存放按类别名称直接读取即可完成批量训练便于快速复现实验加载与评估都很方便。已有107人学习。借助show脚本可直观检查标注质量减少人工整理成本作者还公开了CNN分类网络改进的系列文章可在完成基础训练后继续优化结构、提升精度覆盖从数据预处理、模型搭建到结果对比的完整实验流程。数据集目录结构清晰类别名称与json一一对应适合课程设计、毕业设计或项目演示快速起步。1. 只有一个图像分类数据集的标题拿到手后第一件事是什么手语词汇图像分类数据集在圈子里属于有人做、没人整理的类型。视频类手语语料不少但能直接喂给图像分类模型、又已经打好标签的静态手势图反而不多。所以看到类似标题里这种约 1,100 张的已标注图像分类数据集第一反应不该是嫌它小而是先把它当成一个小样本赛题类别少、单张图不大、普通显卡都能跑适合课程设计、算法对比也适合验证预训练模型在小数据集上到底能迁移多少。你要是刚入图像分类方向这个规模是很好的体检数据集你要是已经做过几个分类项目用它来写基线和消融实验也完全够用。不过已标注三个字信息量有限——它可能是指目录名即标签也可能是一份映射 CSV可能已经按人分好了子集也可能只是文件名带中文词。拿到手先做数据清点和标签核查比急着训练更值钱这也是这篇文章要带你走完的第一步。2. 先确认数据能训练目录结构、文件清点和按人划分2.1 目录式标签小型分类数据集最常见的形态从标题描述看这份数据集是静态图像的分类标注最常见的落法就是每个词汇一个文件夹。整理者从公开手势库、词典截图或自己拍摄收集图像按词汇建目录文件名保持原始名称。我见过的数据集九成都是这种结构少数会附带一份 CSV 或 JSON把文件名和标签一一对应更不规范的是直接把中文词写进文件名比如你好_001.jpg这种必须在代码里统一解析不能靠人眼维护。拿到手的第一步不是急着 split而是先判断它属于哪种形态。最简单的方法是打开根目录看一眼如果有多个子目录且子目录名读出来是词那就是目录式标签如果根目录下全是散图只有一份 labels.csv那就是映射式标签。两种形态后续写 Dataset 类时差别不大但对标签稳定性的要求不一样目录式标签的类别顺序由文件夹名决定映射式标签则要额外检查 CSV 里是否有多余空格、重复文件名和标签拼写不一致。2.2 用 Python 做文件清点张数、格式和损坏图确认形态后我习惯先跑一段清点脚本把三件事一次做完每类张数、图片格式分布、损坏图清单。这样后面划分数据集和写训练代码时心里有底。import os import pandas as pd from PIL import Image root sign_language_dataset # 数据集根目录 rows [] # 遍历根目录下的类别文件夹统计每类样本数 for label_dir in sorted(os.listdir(root)): label_path os.path.join(root, label_dir) if not os.path.isdir(label_path): continue imgs [f for f in os.listdir(label_path) if f.lower().endswith((.jpg, .jpeg, .png, .bmp))] for img in imgs: rows.append({label: label_dir, file: img}) print(f{label_dir}: {len(imgs)} 张) df pd.DataFrame(rows) print(df[label].value_counts()) # 逐张检查图片是否可以完整解码避免训练到一半崩溃 bad_files [] for _, row in df.iterrows(): path os.path.join(root, row[label], row[file]) try: with Image.open(path) as im: im.load() # 不调用 load 只会读文件头坏图不会暴露 except Exception as exc: bad_files.append((path, str(exc))) print(f损坏图片: {len(bad_files)} 张) for p, err in bad_files[:5]: print(p, err)这段代码的逻辑很直白先按子目录把每张图片的文件路径和标签收进 DataFramevalue_counts()输出各类别样本分布第二段用 PIL 的im.load()做真实解码因为有的图后缀是 jpg 但内容已经截断直接训练会在某个 epoch 中途抛异常提前检查成本低得多。参数上注意三点扩展名列表按实际情况加减如果数据里有 .webp 或 .tif需要加进集合否则清点会漏样本根目录路径带中文时建议改用pathlib.Path处理避免 Windows 下 GBK 和 UTF-8 编码打架value_counts()默认按数量降序一眼就能看出哪些类别样本明显偏少这就是后面要处理的类别不均衡。2.3 中文文件名、灰度图和尺寸过小三个边界坑清点脚本跑完通常还会带出三个隐蔽问题。第一个是中文文件名。目录式标签相对安全映射式 CSV 里如果混入了中文文件名pandas 读取时在 Windows 下极易出现编码报错。处理办法是统一用Path对象读路径CSV 保存时用encodingutf-8-sig这样 Excel 打开不乱码Python 也不报错。第二个是灰度图。手语数据集有一部分历史图源是灰度扫描件Image.open打开后 mode 是 L只有一个通道。训练时如果有的图是 RGB、有的是 LDataLoader 里拼 batch 会直接报形状不一致。稳妥做法是在 Dataset 的__getitem__里强制convert(RGB)把灰度图统一复制成三通道下一章代码会写。第三个是尺寸过小。如果原始图只有几十像素指尖和手腕的细节本来就没了再好的模型也补救不回来。1100 张数据里偶发出现这种低分辨率图时我的做法是直接删掉而不是留着拖后腿如果这种图占了相当比例那就要换思路先做超分或者干脆降低任务难度比如只分粗粒度的手势大类。2.4 划分之前先按人分层而不是按文件随机分这是整个数据集处理里最容易被低估的一步。手语图像数据多数来自少数几位志愿者如果采集时每人负责一批词汇随机划分很可能把同一个人的图像同时分进训练集和验证集。模型会偷懒地学习肤色、戒指、手链、背景墙这类身份特征验证分数自然虚高一到新产品上就崩。正确的划分逻辑是按人分组而不是按文件随机分。前提是数据本身有person字段或者能从目录、文件名中解析出来。若有用GroupShuffleSplit做一次分组划分import pandas as pd from sklearn.model_selection import GroupShuffleSplit # 假设 df 里已经有 person 列没有的话只能从文件名或目录名里抽 groups df[person] gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(df, groupsgroups)) train_df df.iloc[train_idx] val_df df.iloc[val_idx] print(train_df[label].value_counts()) print(val_df[label].value_counts())这段代码的核心是groups参数sklearn 在划分时保证同一组的样本只会出现在同一侧从根源上避免身份泄漏。test_size0.2意味着验证集约占 220 张在 1100 张小数据里这个比例是合理的random_state42固定后每次复现结果一致方便复跑对比。如果数据里没有任何 person 信息稳妥做法是放弃单次划分直接进入第 5 章的 K 折交叉验证把同人跨折的风险摊开来看而不是假装它不存在。3. 训练路线为什么 1100 张图优先选预训练微调而不是 YOLO 或 Transformer3.1 选型理由ResNet 级别的预训练权重最稳先回答搜索里一个常见问题为什么不是 yolov8 或者 transformer。YOLOv8 是目标检测框架它的训练需要边界框标注而手语词汇这里是图像分类数据集只有图像级标签没有框。如果图里手已经占画面主体直接做分类就好用检测属于过度设计如果原始图是半身或全身照、手部占比很小那分类模型的输入会被背景稀释这时才需要考虑先检测手部再分类的两段式流程但那就需要额外补充手部框的标注工作量不是一个量级的。Transformer 图像分类这两年确实火但 1100 张这个规模对 ViT 并不友好。ViT 缺乏 CNN 那种内建的局部先验在极小数据集上微调对学习率、权重衰减和数据增强都很敏感调参成本高最终分数不一定超过预训练的 ResNet。我的经验是先用 ResNet18 带着 ImageNet 预训练权重跑通一个强基线后面有精力再对比 Swin-T 或 ViT而不是一上来就追新。按每类 60~80 张估算1100 张大约对应 15~20 个词汇类这个规模用下面的模型梯度就够参考了候选模型预训练权重1100张下的表现预期备注ResNet18ImageNet稳收敛快过拟合风险低baseline 首选ResNet34ImageNet略强于 18速度可接受精调选项ResNet50ImageNet容易过拟合收益有限试一下即可MobileNetV3ImageNet精度略低推理快适合后续上端侧ViT-B/16ImageNet-21k上限高但难调对超参敏感不推荐第一个试Swin-TImageNet介于 ResNet 与 ViT 之间有精力再对比这个排序的核心参考就两条预训练权重是否高质量、小样本下是否容易过拟合。ResNet50 在 1100 张下通常没有明显收益反而更慢MobileNetV3 精度略低但胜在体积小适合要上手机端的人。3.2 自定义 Dataset 和数据增强PyTorch 写法这里给一份可以直接复制的 Dataset 类import os import torch from torch.utils.data import Dataset from PIL import Image class SignLangDataset(Dataset): 读取按目录组织的图像分类数据。label_map 由外部传入保证 id 稳定。 def __init__(self, root, label_map, transformNone): self.samples [] self.transform transform for label_dir, label_id in label_map.items(): folder os.path.join(root, label_dir) for fname in os.listdir(folder): if fname.lower().endswith((.jpg, .jpeg, .png)): self.samples.append((os.path.join(folder, fname), label_id)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] image Image.open(path).convert(RGB) # 统一三通道兼容灰度图 if self.transform: image self.transform(image) return image, label逻辑说明label_map是外部构建的类别名到整数 id字典构建后固定下来不要每次运行重建否则 id 顺序变化会导致 checkpoint 和预测结果错位。convert(RGB)就是前面说的灰度图兼容这一行能省掉很多后期麻烦。transform不在 Dataset 里写死方便训练集和验证集用不同的增强管线。对应的数据增强from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.1), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) val_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ])这里有两个针对手语图像的调参细节。第一一二三这类数字手势有严格朝向RandomRotation过大会把语义破坏掉我把角度压到 10 度。第二RandomResizedCrop的scale下限设 0.7避免裁剪后丢失手掌和指尖的关键信息实际发现裁剪太狠时验证集 acc 掉得很快。如果你拿到的图背景干扰严重可以把scale下限降到 0.5相当于强制模型关注局部手部纹理。3.3 实测训练参数冻结层、学习率和早停import torch import torch.nn as nn from torchvision import models num_classes len(label_map) model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 冻结 stem 和第一个 stage保留 ImageNet 学到的低级纹理特征 for name, param in model.named_parameters(): if name.startswith(conv1) or name.startswith(bn1) or name.startswith(layer1): param.requires_grad False model.fc nn.Linear(model.fc.in_features, num_classes) # fc 层是随机初始化需要更高学习率冻结层不参与更新 optimizer torch.optim.AdamW([ {params: [p for n, p in model.named_parameters() if p.requires_grad], lr: 3e-4} ], weight_decay0.05)模型部分注意几件事冻结conv1/bn1/layer1让预训练权重里的低级特征保持不变。小数据下解冻全部参数很容易让低层被少量样本带偏我在 1100 张的多数实验里都倾向于冻结前半个网络。fc层换成num_classesPyTorch 默认初始化就能跑。优化器用 AdamWweight_decay0.05是中小数据集上相对通用的值如果你发现训练震荡降到 1e-4 再试。提示新版 torchvision 推荐用models.ResNet18_Weights.IMAGENET1K_V1这种显式枚举老代码里的pretrainedTrue已标记为废弃。训练循环用一个简单的早停版本from torch.utils.data import DataLoader train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers2) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers2) best_acc, patience_counter 0.0, 0 for epoch in range(100): model.train() for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() loss nn.CrossEntropyLoss()(model(images), labels) loss.backward() optimizer.step() model.eval() correct total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) preds model(images).argmax(dim1) correct (preds labels).sum().item() total labels.size(0) acc correct / total print(fepoch {epoch:3d}, val_acc {acc:.4f}) if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pt) patience_counter 0 else: patience_counter 1 if patience_counter 15: print(早停触发保存 best_model.pt) break几个参数说明batch_size32对 ResNet18 足够1100 张数据一个 epoch 只有三十多步有 GPU 就上 GPU没有的话 CPU 也能跑通但会慢不少。早停patience15验证集 acc 连续 15 个 epoch 不涨就停小数据集特别容易过拟合不要按固定 epoch 数硬跑。CrossEntropyLoss是图像分类最常用的损失内部把 softmax 和负对数似然合在一起直接拿模型 logits 算即可不需要手动加 softmax。4. 给 1,100 张小数据集的五条避坑记录从数据泄漏到训练玄学这个量级的数据有个共同特征很容易得到一个看起来很高的分数但完全经不起换人实测。以下五条都是我实际踩过的坑按现象、原因、解决三个角度写希望能帮你绕开。4.1 类别失衡让 acc 虚高某几个手势词一个都认不对现象训练结束总准确率 92%看一眼各类别 recall喝水这类只有 30%。原因是 1100 张摊到 15~20 个类别上并不均匀语料收集天然偏向常用词少数类样本数只有多数类的四分之一交叉熵损失被多数类主导模型对少数类几乎没有辨别能力。解决最有效的是WeightedRandomSampler按类别频率的反比给每个样本一个采样权重from torch.utils.data import WeightedRandomSampler labels train_df[label].map(label_map).values class_counts torch.bincount(torch.tensor(labels), minlengthnum_classes) weights 1.0 / class_counts[labels].float() sampler WeightedRandomSampler(weights, num_sampleslen(labels), replacementTrue) train_loader DataLoader(train_ds, batch_size32, samplersampler)这段代码的核心是weights数组每个样本的权重等于它所属类别样本数的倒数。样本数多的类别权重低样本数少的类别权重高采样时小类别被多抽几次梯度不再被多数类垄断。注意用了replacementTrue允许同一张图在一个 epoch 内被重复采样这是加权采样的正常行为。同时把评估指标从整体 acc 换成宏平均 recall每个类别同等权重才能暴露少数类的真实表现。4.2 同一人的手势同时出现在训练和验证集acc 虚高现象验证 acc 高得离谱但实拍几张新场景几乎全错。原因就是数据泄漏同一位志愿者的手势图像跨了训练和验证集模型学到的是肤色、手链、背景墙这些身份特征而不是手势语义。手语图像的采集往往集中在少量志愿者身上随机划分几乎必然中招。解决第一道防线是 2.4 节的GroupShuffleSplit按人分组。如果数据里没有 person 字段可以用预训练模型提取每张图的特征做一次聚类观察同一人的图像是否跨集出现发现泄漏后手工剔除或整组挪到一侧。实在没有线索那就直接承认这个不确定性用第 5 章的 K 折交叉验证出报告而不是给一个虚高的单次划分值。4.3 模型学的是背景而不是手势Grad-CAM 一眼看出现象精度不低但把模型输出做可视化高亮区域落在桌子、墙壁甚至采集者的衣服上手部区域反而没被关注。原因是采集时背景单一背景纹理与标签形成了强相关模型走了一条作弊捷径。解决除了把数据增强里的RandomResizedCrop的 scale 下限调到 0.5还可以加入RandomErasing随机擦除图像中的一块区域强迫模型不要依赖某个固定位置的特征。训练后用 Grad-CAM 复查如果高亮区还在背景说明背景信息已经渗进特征需要回到数据层面重新采集或裁切。这一步属于验证模型到底看哪和只看 acc 是两种完全不同的判断标准。4.4 黑底图用 ImageNet 归一化收敛慢掉点现象同一条数据增强管线在 CIFAR 上没问题换到手势图上 loss 下不去训练曲线震荡。原因在于 ImageNet 的 mean/std 是按自然照片统计的手语数据集里如果大量是黑底或深色背景图归一化会把整体亮度抬得很高手指边缘的对比度反被压缩模型学不到细节。解决小数据集上优先用数据集自身的均值方差做归一化mean images_float.mean(dim(0, 2, 3)) std images_float.std(dim(0, 2, 3))在写训练代码前先加载全部图像算一次均值和标准差替换掉 ImageNet 那组常数值。如果嫌麻烦也可以简单地把归一化换成(x / 255) * 2 - 1映射到 [-1, 1]。这套逻辑对黑白手语图、医学影像这类特殊分布的数据都适用。4.5 训练玄学同一份代码跑三次acc 差 3 个点现象代码没改环境没变三次训练得到 92%、89%、91% 三个结果。原因是 1100 张数据量太小训练集 shuffle 顺序、dropout 随机性都会被放大单次实验的分数不代表模型真实水平。小数据实验里这是一种常见玄学不是模型出 bug。解决固定所有随机种子包括 Python、NumPy、PyTorch 和 DataLoader 的 shuffle保证每次实验可复现。更重要的是报告 K 折交叉验证的均值和标准差。写进结论的应该是mean 89.6 ± 1.8而不是acc 上了 90。这个习惯直接决定了你的实验能不能被复现、能不能支撑你下一步优化方向。5. 别只信准确率K 折、混淆矩阵和 Grad-CAM 是小数据集的照妖镜5.1 分层 K 折把结论变成 mean±std1100 张数据做单次 train/val 划分验证集的波动会直接左右你的判断。常见的做法是 5 折分层交叉验证每折重新训练一次模型最后取均值。这个成本在 1100 张规模下完全可接受一个 ResNet18 单折在普通 GPU 上几分钟就跑完。from sklearn.model_selection import StratifiedKFold from torch.utils.data import Subset skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) fold_scores [] for fold, (train_idx, val_idx) in enumerate(skf.split(samples, labels)): train_subset Subset(full_dataset, train_idx) val_subset Subset(full_dataset, val_idx) # 每个 fold 单独构建模型和优化器记录验证集 acc # 全部跑完后统计 mean 和 std fold_scores.append(fold_acc) print(fK-fold acc: {mean(fold_scores):.4f} ± {std(fold_scores):.4f})分层的意思是按类别比例划分避免某一折里某个词一张都没分到。跑完后不要只看均值还要看各折的方差如果某一折显著低于其他折去查那一折的类别分布多半是少数类全被分进了训练集。5.2 混淆矩阵看哪两个词总在互相认错准确率会骗人混淆矩阵不会。手语词汇里谢谢和不客气、数字一和七这类轮廓相近的词会在矩阵的非对角线位置形成明显热点。把 5 折的混淆矩阵累加后再画能稳定看出模型的结构性盲区。from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay cm confusion_matrix(all_labels, all_preds, labelslist(range(num_classes))) ConfusionMatrixDisplay(cm, display_labelsclass_names).plot(cmapBlues)观察重点不在总体精度而在对角线之外最亮的格子。发现两组词高频互认后可以回头检查原始图是标注反了还是这两个手势本身在静态图上就难区分。静态手语识别有一个天然边界——部分词汇靠动态轨迹区分静态图里表现一致这不是模型的问题是任务本身的信息量天花板。5.3 Grad-CAM证明模型在看手而不是在看背景Grad-CAM 是给模型行为做体检的工具。常见的做法是 pytorch-grad-cam 库把最后一个卷积 block 的 feature map 抽出来配合全连接层的梯度生成热力图。把热力图叠加在原始图上如果高亮区域集中在手指、手掌轮廓上说明模型学到了语义特征如果高亮在背景边缘或衣服纹理上就是 4.3 节说的作弊。这个工具在数据集验收时特别好用挑每个类别一两张典型样本把热力图整理成一张拼图人工扫一遍。手语图像分类最终要落到无障碍场景用户的真实环境远比采集环境复杂模型学的特征是否聚焦在手部直接决定换环境后还有多少可用性。我在项目验收时会把 Grad-CAM 拼图作为必交材料比 acc 数字更有说服力。我的教训是数据集越小评价越要狠。第一次拿到 1100 张时我也贪图省事只看 acc后来被换人实测打脸。从那以后凡是小数据集我都默认跑一套组合拳按人划分、K 折、混淆矩阵、Grad-CAM四个都过了才敢说这个方案能往下走。1100 张不是等着被嘲笑的小玩具把它做到结论扎实比刷一个虚高 acc 有用得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表