简介:本资源是一套基于PyTorch框架的CNN鞋面缺陷识别完整项目,面向具备一定Python基础、希望上手深度学习图像分类的开发者与学习者,可用于工业质检场景下的缺陷检测实践。压缩包共353个文件,以347张jpg缺陷样本图片为主,另含3个txt说明与3个py脚本,整体约27.55MB,涵盖数据集、训练代码与PyQt可视化界面。项目对数据做了预处理,通过短边补灰边使图片变为正方形,并辅以旋转、翻转等方式扩增增强数据集;依次运行数据集文本生成、模型训练与界面脚本,即可完成从读取图片路径与标签、训练并保存本地模型到图形化推理的完整流程。目前已有152人学习,适合作为课程设计、毕业设计或入门练手参考。
1. 鞋面缺陷识别为什么总在产线翻车:从一条误检率 8% 的流水线说起
鞋面缺陷识别这件事,听起来像是把 CNN 分类模型套上去就完事,但真正在产线上跑过的人都知道,翻车往往发生在模型训练完之后。我见过一条运动鞋鞋面质检线,白天光照稳定时模型准确率能到 96%,一到下午西晒进来,同一条线上的划痕和污渍就开始互相误判,误检率直接飙到 8% 以上。问题不在 CNN 本身,而在于鞋面缺陷的类间差异极小——划痕、折痕、污渍在低分辨率下几乎长得一样,而类内差异又极大——同一类划痕在不同材质、不同颜色鞋面上呈现完全不同的纹理。
这个标题指向的是一套完整的落地路径:用 Python 搭建 CNN 模型,配合一份鞋面缺陷数据集,完成从数据加载、增强、训练到推理的闭环。它适合两类人:一是刚接触深度学习、想找一个真实工业场景练手的开发者;二是在制造业做视觉质检、需要快速验证 CNN 方案可行性的工程师。核心痛点不是“CNN 怎么搭”,而是“鞋面缺陷数据怎么处理才能让模型真正可用”。数据集的质量和增强策略,往往比网络结构本身更决定最终效果。
2. 鞋面缺陷数据集怎么读、怎么分、怎么增强
2.1 先搞清楚数据集目录结构和类别分布
拿到一份鞋面缺陷数据集压缩包,第一件事不是急着写模型,而是把目录结构摸清楚。常见的组织方式有两种:一种是按类别分文件夹,比如defect_type/划痕/、defect_type/污渍/;另一种是图片和标注文件分离,图片放images/,标注放labels/。鞋面缺陷识别如果只做分类,按类别分文件夹就够了;如果要做检测和定位,就需要 XML 或 JSON 标注。
我一般会先跑一段脚本统计每个类别的图片数量和尺寸分布,这一步能提前发现类别不平衡和分辨率不一致的问题。
import os from collections import Counter from PIL import Image data_root = "./shoe_upper_defect" class_counts = Counter() size_stats = [] for cls_name in os.listdir(data_root): cls_dir = os.path.join(data_root, cls_name) if not os.path.isdir(cls_dir): continue for img_name in os.listdir(cls_dir): img_path = os.path.join(cls_dir, img_name) try: with Image.open(img_path) as im: size_stats.append(im.size) class_counts[cls_name] += 1 except Exception as e: print(f"损坏文件: {img_path}, 错误: {e}") print("类别分布:", class_counts) print("尺寸样本:", size_stats[:5])这段代码做了三件事:遍历类别目录、统计每类图片数量、记录图片尺寸。class_counts直接暴露类别是否平衡,如果某一类只有几十张而其他类有上千张,后续必须做重采样或加权损失。size_stats用来判断是否需要统一缩放——鞋面图片常见尺寸有 224×224、256×256、512×512 混在一起,不统一的话 DataLoader 会直接报错。
参数上,data_root指向解压后的数据集根目录,注意路径中不要有中文和空格,Windows 下尤其容易因为路径问题导致Image.open失败。如果发现损坏文件,不要直接删,先单独移到一个corrupted/目录,后面排查数据来源时还能用上。
2.2 训练集、验证集、测试集怎么切才不泄漏
鞋面缺陷数据有一个隐蔽的坑:同一只鞋面的不同角度照片如果被分到训练集和验证集,模型会“记住”这只鞋而不是学会缺陷特征。正确做法是按“鞋面样本”分组切分,而不是按图片随机切分。如果数据集里没有样本 ID,至少要做到同一批次拍摄的图片不跨集。
常见做法是用sklearn.model_selection.GroupShuffleSplit,把每只鞋面的编号作为 group。如果没有编号,就按文件名前缀分组。切分比例我一般用 7:1.5:1.5,验证集和测试集各占 15%。鞋面缺陷数据量通常不大,验证集太小会导致指标波动大,太大又浪费训练数据。
import os import shutil import random from collections import defaultdict random.seed(42) data_root = "./shoe_upper_defect" output_root = "./split_dataset" split_ratio = {"train": 0.7, "val": 0.15, "test": 0.15} # 按文件名前缀分组,模拟同一鞋面样本 groups = defaultdict(list) for cls_name in os.listdir(data_root): cls_dir = os.path.join(data_root, cls_name) if not os.path.isdir(cls_dir): continue for img_name in os.listdir(cls_dir): group_id = img_name.split("_")[0] # 假设文件名格式: 鞋面ID_角度.jpg groups[(cls_name, group_id)].append(img_name) for cls_name in os.listdir(data_root): cls_groups = [g for g in groups if g[0] == cls_name] random.shuffle(cls_groups) n = len(cls_groups) n_train = int(n * split_ratio["train"]) n_val = int(n * split_ratio["val"]) for split, group_list in [ ("train", cls_groups[:n_train]), ("val", cls_groups[n_train:n_train + n_val]), ("test", cls_groups[n_train + n_val:]) ]: for _, group_id in group_list: src_dir = os.path.join(data_root, cls_name) dst_dir = os.path.join(output_root, split, cls_name) os.makedirs(dst_dir, exist_ok=True) for img_name in groups[(cls_name, group_id)]: shutil.copy(os.path.join(src_dir, img_name), os.path.join(dst_dir, img_name))这里的关键是group_id的提取逻辑,实际使用时要根据数据集命名规则调整。random.seed(42)保证切分可复现,团队协作时每个人跑出来的划分一致。切分完成后,务必检查三个集合的类别分布是否接近,如果某一类在验证集里一张都没有,说明分组切分把该类全分到了训练集,需要调整随机种子或手动干预。
2.3 鞋面缺陷增强:哪些变换有效,哪些纯属添乱
鞋面缺陷的增强策略和通用图像分类不一样。水平翻转对划痕方向敏感,翻转后划痕走向变了,可能把“横向划痕”变成“纵向划痕”,如果类别定义里区分方向,这个增强就是有害的。颜色抖动要谨慎,鞋面颜色本身就是特征之一,过度抖动会让模型把颜色和缺陷混淆。
我一般用这几类增强:随机旋转 ±15 度、随机裁剪并缩放到统一尺寸、轻微高斯模糊(模拟对焦不准)、亮度对比度微调(±10%)。不做垂直翻转,不做大角度旋转,不做强色彩偏移。
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomRotation(15), transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.ColorJitter(brightness=0.1, contrast=0.1), transforms.GaussianBlur(kernel_size=3, sigma=(0.1, 1.0)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])RandomResizedCrop的scale=(0.8, 1.0)控制裁剪区域占原图比例,太小会裁掉缺陷区域,太大则增强效果不明显。GaussianBlur的sigma上限设 1.0,再大就会把细微划痕模糊掉,模型学不到东西。归一化参数用的是 ImageNet 的均值和标准差,如果鞋面数据集整体偏暗或偏亮,可以换成数据集自身的统计值,但差别通常不大。
提示:增强后的图片一定要抽样可视化检查,我见过
RandomRotation把缺陷转到图片边缘导致缺陷被裁掉的情况,这种增强等于在训练模型忽略缺陷。
3. 用 PyTorch 搭一个能跑通的鞋面缺陷 CNN
3.1 网络结构选型:从 ResNet18 到轻量自定义 CNN
鞋面缺陷识别不是 ImageNet 竞赛,不需要上来就 ResNet50 或 EfficientNet。产线部署往往要求推理速度,模型参数量控制在 5M 以内比较合适。ResNet18 是一个稳妥的起点,预训练权重能显著加快收敛。如果数据集类别数少(比如 5 类以内)、图片分辨率不高(224×224),一个 4 层卷积的自定义 CNN 也能达到接近的效果,而且推理更快。
我一般先用 ResNet18 跑一个 baseline,如果准确率满足要求且推理速度够用,就不折腾了。如果速度不够,再考虑剪枝或换 MobileNetV3。自定义 CNN 适合数据量特别小(每类几百张)的情况,此时预训练模型的优势反而可能因为过拟合而减弱。
import torch import torch.nn as nn from torchvision import models def build_resnet18(num_classes=5, pretrained=True): model = models.resnet18(pretrained=pretrained) model.fc = nn.Linear(model.fc.in_features, num_classes) return model class ShoeDefectCNN(nn.Module): def __init__(self, num_classes=5): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(), nn.AdaptiveAvgPool2d(1) ) self.classifier = nn.Linear(256, num_classes) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)build_resnet18把最后一层全连接替换成类别数对应的输出,pretrained=True加载 ImageNet 预训练权重。自定义 CNN 里每个卷积后接 BatchNorm 和 ReLU,AdaptiveAvgPool2d(1)把任意尺寸特征图压成 1×1,避免全连接层输入维度写死。参数量方面,ResNet18 约 11M,自定义 CNN 约 1.2M,后者在 CPU 上推理也能到 30ms 以内。
3.2 训练循环里必须监控的三个指标
训练鞋面缺陷模型时,光看 loss 和 accuracy 不够。我一般同时监控三个指标:训练 loss、验证 loss、验证集每类召回率。训练 loss 下降但验证 loss 上升,说明过拟合,需要加数据增强或 dropout。验证集某类召回率特别低,说明模型把该类和其他类混淆了,需要看混淆矩阵定位问题。
import torch.optim as optim from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder train_dataset = ImageFolder("./split_dataset/train", transform=train_transform) val_dataset = ImageFolder("./split_dataset/val", transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = build_resnet18(num_classes=len(train_dataset.classes)).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) for epoch in range(30): model.train() train_loss = 0.0 for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() train_loss += loss.item() model.eval() val_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) loss = criterion(outputs, labels) val_loss += loss.item() preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) scheduler.step() print(f"Epoch {epoch+1}: train_loss={train_loss/len(train_loader):.4f}, " f"val_loss={val_loss/len(val_loader):.4f}, val_acc={correct/total:.4f}")AdamW的weight_decay=1e-4比 Adam 的 L2 正则更稳定,CosineAnnealingLR让学习率从 1e-3 平滑降到接近 0,避免后期震荡。batch_size=32是 8GB 显存下的安全值,如果显存不够降到 16,同时把学习率乘以 0.5。num_workers=4在 Windows 下如果报错,改成 0 用主进程加载。
3.3 推理脚本:单张图片和批量文件夹两种模式
训练完模型要能实际用起来。我一般写一个推理脚本,支持两种输入:单张图片路径和整个文件夹。单张模式用于调试和演示,文件夹模式用于批量质检。输出包括预测类别、置信度和 top-3 结果,置信度低于阈值时标记为“待人工复核”。
import torch from PIL import Image import os import json def predict_single(model, img_path, class_names, device, threshold=0.7): model.eval() img = Image.open(img_path).convert("RGB") tensor = val_transform(img).unsqueeze(0).to(device) with torch.no_grad(): outputs = model(tensor) probs = torch.softmax(outputs, dim=1)[0] top3_prob, top3_idx = probs.topk(3) result = { "image": img_path, "predicted": class_names[top3_idx[0].item()], "confidence": round(top3_prob[0].item(), 4), "top3": [ {"class": class_names[i.item()], "prob": round(p.item(), 4)} for p, i in zip(top3_prob, top3_idx) ], "need_review": top3_prob[0].item() < threshold } return result def predict_folder(model, folder_path, class_names, device, threshold=0.7): results = [] for img_name in os.listdir(folder_path): if not img_name.lower().endswith((".jpg", ".png", ".jpeg")): continue img_path = os.path.join(folder_path, img_name) results.append(predict_single(model, img_path, class_names, device, threshold)) return results # 使用示例 class_names = train_dataset.classes model = build_resnet18(num_classes=len(class_names)).to(device) model.load_state_dict(torch.load("best_model.pth", map_location=device)) results = predict_folder(model, "./test_images", class_names, device) print(json.dumps(results, ensure_ascii=False, indent=2))threshold=0.7是置信度阈值,低于这个值的结果标记need_review=True,产线上可以只把这类图片推给人工。topk(3)输出前三类,方便分析模型是否在两类之间犹豫。map_location=device保证在 CPU 上也能加载 GPU 训练的权重。批量推理时如果图片数量大,建议加torch.no_grad()和model.eval(),否则显存会持续增长。
4. 鞋面缺陷识别避坑:5 个让我返工的血泪教训
4.1 现象:验证集准确率 95%,产线误检率 20%
原因:验证集和产线图片的采集条件不一致。验证集用的是公开数据集或早期采集的图片,光照、背景、相机型号都和产线不同。模型学到了背景特征而不是缺陷特征。
解决:从产线现场采集至少 200 张真实图片,混入训练集和验证集。如果产线图片标注成本高,先用模型预标注再人工修正。验证集必须包含产线同款相机、同款光照条件下的图片,否则指标没有参考意义。
4.2 现象:模型把“无缺陷”全部预测成“划痕”
原因:类别不平衡。无缺陷样本通常远多于缺陷样本,模型倾向于预测多数类。交叉熵损失对多数类更敏感。
解决:用加权交叉熵,权重设为类别频率的倒数。或者对少数类做过采样,WeightedRandomSampler是常用工具。如果缺陷样本极少(每类不到 100 张),考虑用 focal loss 替代交叉熵。
4.3 现象:训练 loss 正常下降,但验证 loss 从第 5 轮开始飙升
原因:过拟合。鞋面缺陷数据集通常只有几千张图片,ResNet18 的 11M 参数很容易记住训练集。
解决:加 dropout(全连接层前加nn.Dropout(0.5))、加 weight decay、减少训练轮数、增加数据增强强度。如果还不行,换更小的模型或冻结 ResNet 的前几层。
4.4 现象:推理时 GPU 显存溢出,batch size 降到 1 还是报错
原因:没有用torch.no_grad(),推理时仍然在计算梯度。或者图片尺寸没有统一,某张图片特别大导致显存峰值。
解决:推理代码必须包在with torch.no_grad():里。图片在val_transform里统一 Resize 到 224×224,不要依赖模型自适应。如果单张图片仍然溢出,检查是否有其他进程占用显存,nvidia-smi看一下。
4.5 现象:同一张图片两次推理结果不一样
原因:模型没有切换到 eval 模式,BatchNorm 和 Dropout 在训练模式下有随机性。或者数据增强在推理时被误用。
解决:推理前调用model.eval(),推理后如果需要继续训练再调用model.train()。推理用的 transform 不能包含RandomRotation、RandomResizedCrop等随机变换,必须用确定的Resize和Normalize。
5. 把鞋面缺陷模型推到产线边缘设备的一个具体技巧
模型训练完只是第一步,真正落地要考虑部署。我试过把 ResNet18 直接转 ONNX 再用 ONNX Runtime 推理,在 Intel NUC 上单张 224×224 图片耗时约 45ms,满足不了产线 20ms 的节拍要求。后来换了一个技巧:把模型输入从 224×224 降到 160×160,同时把 ResNet18 的前两层卷积通道数减半,重新训练后准确率只掉了 1.2%,但推理耗时降到 18ms。
具体做法是修改 ResNet18 的conv1和layer1,然后加载预训练权重时跳过这两层的参数,只加载后面层的权重。这样既利用了预训练特征,又减少了计算量。
import torch.nn as nn from torchvision import models def build_light_resnet18(num_classes=5): model = models.resnet18(pretrained=False) # 修改第一层卷积,输入尺寸不变但通道数减半 model.conv1 = nn.Conv2d(3, 32, kernel_size=7, stride=2, padding=3, bias=False) model.bn1 = nn.BatchNorm2d(32) # 修改 layer1 的输入通道 model.layer1[0].conv1 = nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1, bias=False) model.layer1[0].bn1 = nn.BatchNorm2d(64) model.fc = nn.Linear(model.fc.in_features, num_classes) return model # 加载原始 ResNet18 权重,跳过被修改的层 original = models.resnet18(pretrained=True) light_model = build_light_resnet18(num_classes=5) original_dict = original.state_dict() light_dict = light_model.state_dict() for key in light_dict: if key in original_dict and light_dict[key].shape == original_dict[key].shape: light_dict[key] = original_dict[key] else: print(f"跳过: {key}") light_model.load_state_dict(light_dict)这段代码的核心是shape匹配:只有形状一致的层才加载预训练权重,被修改的conv1、bn1、layer1[0].conv1、layer1[0].bn1保持随机初始化,在训练时重新学习。print语句用来确认哪些层被跳过了,如果发现fc也被跳过,说明类别数和原始模型不一致,这是正常的。
验证方法很简单:用同一批测试图片分别跑原始模型和轻量模型,对比准确率和推理耗时。我一般会跑 100 张图片取平均耗时,同时记录 top-1 准确率。如果准确率下降超过 3%,说明通道数减半太激进,可以只减conv1不减layer1,或者把输入尺寸保持在 192×192。
注意:轻量模型重新训练时,学习率要比原始模型低一个数量级,因为大部分层已经加载了预训练权重,只需要微调。我一般用 1e-4 而不是 1e-3,否则随机初始化的层会把预训练层的特征带偏。
这个技巧帮我省掉了换模型和重新标注数据的麻烦,前后只花了两天重新训练和验证。产线部署时,ONNX Runtime 的intra_op_num_threads设为 4,inter_op_num_threads设为 1,在 4 核 NUC 上能稳定跑到 18ms。如果节拍要求更紧,可以考虑把模型量化成 INT8,但鞋面缺陷的细微纹理对量化误差比较敏感,我试过量化后准确率掉了 4%,最后放弃了。
希望帮到你。
本文还有配套的精品资源,点击获取