☰
工业缺陷检测中的图像分类实战:从数据到部署的完整方案
2026/10/8 7:32:12 网站建设 项目流程

简介:基于图像分类的工业缺陷检测项目,以视频讲解+完整代码的形式覆盖从数据准备到模型训练、预测的全过程。资源面向工业质检工程师、自动化设备调试人员及图像识别人工智能学习者,重点解决产品表面划痕、裂纹、异物、颜色偏差等缺陷的自动分类与识别问题。压缩包共1823个文件,包含1802张BMP格式工业缺陷样本图像、6个MP4分阶段讲解视频、5个XML标注文件、4个Python训练/预测脚本及模型权重、配置文件等,总大小约346MB,按项目介绍、数据集介绍、自定义数据集、模型定义、定义训练过程、定义预测过程六个阶段清晰分目,便于按需跳转和对照实践。已有86人学习下载,项目不仅从原理上解释了CNN等图像分类模型的应用,还通过自定义数据集环节指导用户按工业场景收集和标注图像,配合实际调参、训练和评估流程,可帮助学习者快速构建并部署可用的工业缺陷检测系统,提升产线自动化质检效率。

1. 工业缺陷检测,为什么说图像分类够用

很多工程师一提到工业缺陷检测,第一反应就是上YOLO。但在实际产线里,有大量需求只是“判断这块产品有没有缺陷”或者“把缺陷归成哪几类”,这种场景用图像分类就够用了,而且比目标检测稳定得多,也更容易落地。

这个标题打包的正是这样一套方案:视频讲解负责把思路和调参逻辑讲清楚,代码把数据加载、模型训练、验证和部署串成一条线。适合刚接手质检项目的工程师,也适合手上有几千张样本、想快速搭一个分类模型评估效果的团队。下面按数据、模型、训练、避坑、部署的顺序,把每个环节的工程细节和参数选择展开讲。

2. 数据决定90%:分类样本采集、标注与划分的工程细节

2.1 先搞清楚:什么场景该用分类,什么场景该用检测

图像分类解决的是“这张图属于哪一类”的问题。工业缺陷检测里最常见的分类任务是二分类:正常/缺陷;其次是按缺陷形态分类:划伤、压痕、脏污、气泡等。

适用条件有两个:缺陷类别互斥,且不需要给出位置。如果你关心的只是“这批产品能不能放行”,分类就够了;如果你需要告诉机械手“缺陷在右下角”,那就应该去用目标检测。很多团队在这里没想清楚,直接上检测模型,标注成本翻了几倍,效果还不一定更好。

为什么分类方案在工业界比检测更稳?这是血泪经验。目标检测要画框,标注一致性很难保证,同一个缺陷十个人能画出十种框;分类只要把样本放进对应文件夹,标注质量高得多。而且兼顾“在哪里”和“是什么”时,模型复杂度上升,训练和调参翻车概率也明显增加。所以先想清楚需求边界,再决定技术路线。

2.2 采集与标注:用文件夹当标签,按批次划分数据

采集时必须固定工位。相机、镜头、光源和产品位姿,在训练和推断时保持一致。很多项目死因不是模型,而是采集环境变化:训练时用实验室照片,产线现场换成白光,模型准确率立刻下滑。这属于数据问题,不是算法问题,后面怎么调参都救不回来。

标注直接用目录结构做标签,常见做法是:

data/ train/ ok/ scratch/ stain/ val/ ok/ scratch/ stain/ test/ ok/ scratch/ stain/

代码说明:PyTorch 的 ImageFolder 会按子目录字母顺序生成类别编号,目录名就是语义标签,训练代码里通过dataset.classes可以读出来。

划分数据时有一个容易踩的坑:不要随机划分。同一块产品被裁成多个 patch 时,随机划分会让同一个产品同时出现在训练集和验证集里,这叫数据泄露,验证集准确率会虚高。正确做法是按“产品批次”划分,一批产品要么全进训练集,要么全进验证集。

数量方面,我一般要求每个类别最少 300 张,能到 500 张以上最好。缺陷类不足时,先补拍,再考虑合成缺陷,最后才是数据增强。对分类来说,每类 100 张也能跑,但泛化能力很差,基本是在背训练集。

还有一个容易被忽略的事:标注定义的边界要写清楚。划伤和磨损如果肉眼都分不清,建议直接合并成一个“表面损伤”类,否则模型会学到标注员的随机噪声,评测指标很好看,落地就露馅。类别定义文档比代码本身还重要,这是做过几个项目之后才真正理解的。

2.3 样本量不够?增强分两层:在线增强与缺陷样本扩充

训练时最常见的增强是翻转、旋转、颜色抖动。但工业图像和 ImageNet 里的自然图不一样,背景固定,缺陷可能只有几十个像素,增强做过头会把“稀疏缺陷”增强成“满屏花纹”。我用下来比较稳的一套是:

train_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.3, contrast=0.2, saturation=0.2), ])

说明:这里故意不用RandomResizedCrop。随机裁剪和缩放会破坏缺陷的尺度特征,让模型去学产品纹理而不是缺陷本身。旋转和颜色抖动模拟的是产品摆放角度和光源亮度波动,这两个才是产线上最常见的干扰源。

第二层增强是离线扩充。针对缺陷样本少的情况,把每张缺陷图旋转 90、180、270 度,加上水平翻转,另存为新的样本。好处是训练集直观变大,代码容易读,也方便给客户验收;缺点是有可能过拟合到固定的增强操作。想要更精细,用 albumentations 做在线增强也可以,但工业项目里离线增强的接受度往往更高。

动手训练前我先做一次数据巡检:

检查项建议值原因
每类图像分辨率是否一致一致或记录后统一缩放分辨率不一致会让模型学到尺寸特征
亮度均值是否稳定类间差异小于 20%亮度差异大说明光源不稳定
图片文件是否损坏逐个解码单张坏图会让训练中途崩溃

这个巡检脚本十分钟能写完,但能避免后面两天的无效训练。我见过最典型的问题:某类全是 1920×1080,另一类全是 800×600,模型学到的是分辨率特征,不是产品特征。

3. 模型选择与预训练:ResNet、EfficientNet、MobileNetV2 怎么挑

3.1 输入尺寸与缺陷尺度的关系:整图 224 还是切 patch

分类模型的标准输入是 224×224,但产线相机拍出来的图往往有几千像素。缺陷如果占图片面积还不到 1%,直接把整图缩放到 224×224,缺陷只剩两三个像素,模型再怎么训练也学不到。

常见做法是分两步走:先用传统图像处理或固定坐标标定,锁定缺陷可能出现的 ROI 区域,再把 ROI 缩放进 224×224。如果缺陷位置随机,就切成互相重叠的小 patch,每个 patch 单独分类,相当于把分类器当滑动窗口用。

切 patch 的代码不复杂,我一般这么写:

import numpy as np def extract_patches(image, patch_size=224, overlap=0.25): h, w = image.shape[:2] stride = int(patch_size * (1 - overlap)) patches, positions = [], [] for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): patches.append(image[y:y + patch_size, x:x + patch_size]) positions.append((x, y)) return patches, positions

代码说明:overlap让相邻 patch 之间保留重叠区域,避免缺陷正好落在切缝上。步长等于patch_size * (1 - overlap),重叠率 0.25 时步长是 168 像素。整图推理时,把每个 patch 的分类概率做平均或取最大,再判断整图是否含缺陷,效果比单张缩放好很多。

判断该不该切 patch,有一个直观标准:把原图缩成 224×224,你作为人肉能不能看出缺陷?如果看不出,必须切。切 patch 后单张图推理次数增加了,但每次推理的像素总量反而少了,因为跳过了大量背景,实际部署时这是一个值得做的权衡。

3.2 模型对比表与选型结论:从 ResNet18 到 EfficientNet-B0

工业部署里最常被比较的是下面几类:

模型参数量ImageNet Top-1(参考)CPU 推理耗时(224x224)适合场景
ResNet1811.7M69.8%约 10ms 级小数据、快速验证
ResNet5025.6M76.1%约 20ms 级准确率优先且有 GPU
MobileNetV23.5M72.2%约 5ms 级边缘盒子、低算力
EfficientNet-B05.3M77.1%约 8ms 级数据量较多时取精度

数值都是公开 ImageNet 上的参考值,不直接代表你的缺陷数据集。真正的差距要在自己的数据上跑出来,但选型方向可以参考。

我的结论是:只有几百张样本时,ResNet18 做迁移学习比 EfficientNet 稳,因为参数少,不容易把训练集的噪声背下来。算力只有 CPU 边缘盒时,MobileNetV2 是综合成本最低的选择,这也是很多项目里能看到 mobilenetv2 代码示例的原因。GPU 充足、类别又多,先跑 EfficientNet-B0,训练量不大,性价比高。

顺带提一句,最新的图像分类模型如 ViT 在小数据上并不占优,工业场景里的图像分类算法,黄金组合依然是“CNN 加迁移学习”。不要为了追新把项目变成论文复现。如果缺陷样本真的少到补无可补,还有人会去复现 PatchCore 这类无监督方案,这是另一条技术路线,它更接近特征提取加距离判断,和图像分类的训练流程完全不同,先别混着用。

3.3 迁移学习固定套路:先冻结后解冻,学习率分层

用 ImageNet 预训练权重是分类任务的默认动作。有人会怀疑工业图和自然图差异太大,预训练权重没用,实际上第一层卷积学到的边缘、纹理、颜色斑块是通用的,转移过来能省大量训练时间。完全从零训练在小样本上会非常慢,准确率也常常连预训练微调的一半都达不到。

标准操作分两个阶段。第一阶段冻结 backbone,只训练最后的全连接层,让分类头先适应新类别,学习率可以给到 1e-3。第二阶段解冻 backbone,用 1e-4 到 3e-4 的学习率整体微调。注意第二阶段学习率一定要比第一阶段低,不然会把预训练权重冲掉,模型反而变笨。

如果数据量中等但类别不均衡,损失函数里加类别权重;如果数据量很小,每类只有一百张左右,冻结阶段多训几个 epoch,再用更小的学习率解冻。这套流程是行业内的标准做法,只要你做的项目是图像分类,必然绕不开。

关于预训练权重加载,直接用 torchvision 的 weights 参数最省事,代码会按需自动下载。内网部署时提前把权重文件放到本地缓存目录,避免产线服务器联网失败卡住。这一点在项目环境里很容易被忽略,但遇到过一次就会长记性。

3.4 混淆矩阵与评估指标:别只盯着 accuracy

分类模型的 accuracy 在缺陷检测里极具迷惑性。假设正常产品占 95%,缺陷只占 5%,模型把所有图都判成正常,accuracy 也有 95%,但上线后全是漏检。缺陷检测更关心漏检率,也就是假阴性,而且不同缺陷的漏检代价不一样,气泡漏检和划伤漏检通常不能等价比较。

所以在验证阶段要打印混淆矩阵和 precision/recall。召回率最低的类别,就是模型最容易漏的类别,这个信息直接影响后面阈值怎么调。

from sklearn.metrics import confusion_matrix, classification_report y_true = [] # 从验证集推理时收集真实标签 y_pred = [] # 从验证集推理时收集预测标签 print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, target_names=class_names))

代码说明:classification_report会输出每个类别的精确率、召回率和 F1。正常类召回率高但缺陷类召回率低,说明模型在牺牲少数类换取整体准确率,下一步要做的是类别加权或阈值调整,而不是换更强的网络。

4. 用 PyTorch 跑通训练:增强、损失函数与四个关键超参数

4.1 用 ImageFolder 加载数据和增强 pipeline

数据准备好了,训练端直接用 PyTorch 的 ImageFolder,目录结构就是标签,不需要额外写 CSV。增强 pipeline 这样配:

from torchvision import datasets, transforms from torch.utils.data import DataLoader train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.3, contrast=0.2), 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]), ]) train_dataset = datasets.ImageFolder('data/train', train_transform) val_dataset = datasets.ImageFolder('data/val', val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True)

代码说明:ImageFolder 会根据子目录名字母序生成标签,比如scratch是 0,stain是 1。Normalize 的均值方差用的是 ImageNet 统计量,训练和推理必须保持一致,否则颜色分布被破坏,模型效果直接跳水。

DataLoader 参数里,num_workers=4让 CPU 用多个进程做图片解码,pin_memory=True减少主机到 GPU 的拷贝时间。这两个参数对训练速度影响很大,很多人训练慢就是卡在数据加载而不是 GPU。

如果缺陷样本非常少,不要用shuffle=True,换成权重采样:

from torch.utils.data import WeightedRandomSampler class_counts = torch.bincount(torch.tensor(train_dataset.targets)) class_weights = 1.0 / class_counts.float() sample_weights = class_weights[torch.tensor(train_dataset.targets)] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True) train_loader = DataLoader(train_dataset, batch_size=32, sampler=sampler, num_workers=4, pin_memory=True)

代码说明:sample_weights按照每个类别的样本数倒数计算,正常类权重低,缺陷类权重高。每次迭代采样时,网络更大概率抽到缺陷图,比手动给 loss 加权更平滑,缺点是同一张缺陷图可能在一个 epoch 里重复出现,所以训练 epoch 数要适当减小。

参数推荐值说明
batch_size32 - 64太小 loss 震荡,太大显存受限
num_workers4 - 8解码和增强交给 CPU,别让 GPU 空等
pin_memoryTrue减少数据传输时间,训练提速
learning rate1e-4 - 3e-3从头训练用 1e-3,迁移学习用 1e-4

4.2 训练循环:CrossEntropy 与 AdamW 的关键搭配

以 MobileNetV2 为例,完整训练循环这样写:

import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR import torchvision.models as models num_classes = len(train_dataset.classes) model = models.mobilenet_v2(weights=models.MobileNet_V2_Weights.IMAGENET1K_V1) model.classifier[1] = nn.Linear(model.last_channel, num_classes) model = model.cuda() criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=30) best_acc = 0 for epoch in range(30): model.train() train_loss = 0.0 for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() train_loss += loss.item() * labels.size(0) model.eval() correct = total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.cuda(), labels.cuda() outputs = model(images) preds = torch.argmax(outputs, dim=1) correct += (preds == labels).sum().item() total += labels.size(0) acc = correct / total if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "best_model.pth") print(f"epoch={epoch} train_loss={train_loss / len(train_loader.dataset):.4f} valid_acc={acc:.4f}")

代码说明:CrossEntropyLoss 内部自带 softmax,所以模型最后不需要再接 Softmax 层。AdamW 比 Adam 更适合做权重衰减,weight_decay=1e-4相当于给大权重视惩罚,能有效压过拟合。CosineAnnealingLR 的T_max=30和总 epoch 数保持一致,学习率从 1e-4 平滑降到接近 0。

best_acc只保存验证集上最好的权重,而不是最后一个 epoch 的权重。最后一步,加载最佳权重做进一步评估:

model = models.mobilenet_v2(weights=None) model.classifier[1] = nn.Linear(model.last_channel, num_classes) model.load_state_dict(torch.load("best_model.pth"))

代码说明:保存 state_dict 后加载时,必须先构造相同结构再 load_state_dict。这里weights=None没问题,因为真正要用的权重是从 best_model.pth 里读进来的。

4.3 只有几百张样本:冻结 backbone 与早停

样本量不足时,第一步先把 backbone 冻结:

for name, param in model.features.named_parameters(): param.requires_grad = False optimizer = optim.AdamW(model.classifier.parameters(), lr=1e-3)

代码说明:model.features是 MobileNetV2 的 backbone,冻结之后只有分类头更新。这里学习率可以给到 1e-3,因为分类头是随机初始化,需要学得快一点。

训练三到五个 epoch 后,解冻全部层:

for name, param in model.features.named_parameters(): param.requires_grad = True optimizer = optim.AdamW(model.parameters(), lr=1e-4)

代码说明:解冻后整个网络一起微调,学习率必须降到 1e-4,用来精调预训练特征,而不是推翻重学。

早停可以直接看验证集 loss,连续五个 epoch 不下降就停。还有一个便宜好用的技巧:nn.CrossEntropyLoss(label_smoothing=0.1),标签平滑让模型不要对训练集过于自信,对小样本场景有实打实的好处。

4.4 用 loss 曲线看一眼:模型到底在学什么

训练过程中把每个 epoch 的 train_loss 和 val_acc 记下来画成曲线。常见情况有几种:train_loss 一直降,val_acc 先升后降,这是过拟合,早停或加强增强;train_loss 和 val_acc 都降得慢,学习率太低;train_loss 震荡很厉害,batch_size 太小或者学习率太高。

特征图也是一个好帮手。MobileNetV2 的model.features输出的是最后一层特征图,直接取通道均值可视化,能看到模型注意力是落在缺陷上,还是落在背景纹理上。这个步骤一开始看起来有点像玄学,但真的能帮你判断增强策略是否有效。

5. 避坑笔记:分类做缺陷检测的 5 个典型问题与排查

这些坑是我在项目里反复遇到过的,现象、原因、解决一套写清楚,遇到类似症状可以直接对照。

5.1 现象:val 准确率 97%,产线误杀率还是高

原因:训练集和产线的光照、工业相机型号、镜头光圈不一致,模型学到的是室内灯光下的颜色分布,不是真正的缺陷特征。很多团队遇到这个情况会反复调模型,但问题在数据分布,不在模型结构。

解决:从产线抽 200 张真实缺陷图和 200 张正常图,单独固定成一个“野外测试集”,不参与训练和调参,只在最后做一次测试。只要这个测试集准确率不达标,就不要改模型,先回产线补数据、调整光源位置,再做增强。

5.2 现象:模型把所有东西都判成“无缺陷”

原因:正常样本数量远大于缺陷样本,CrossEntropy 的梯度被多数类主导,网络发现全预测成正常类之后 loss 已经很低。表面上看 val 准确率很高,其实正常类占 95% 的时候,闭眼猜都能有 95% 准确率。

解决:使用 WeightedRandomSampler,或者直接在 loss 里加权重:

class_counts = torch.bincount(torch.tensor(train_dataset.targets)) weight = class_counts.float().min() / class_counts.float() criterion = nn.CrossEntropyLoss(weight=weight.cuda())

代码说明:这里 weight 让样本少的类别获得更大损失权重。比如正常类 5000 张、缺陷类 500 张,正常类权重是 0.1,缺陷类权重是 1.0,模型每犯一次缺陷类错误都要付出更大代价。权重差超过 10 倍时容易训练震荡,需要配合学习率下调。

5.3 现象:运行代码提示“找不到 msvcp140.dll 无法继续执行代码是什么原因”

原因:Windows 环境缺少 Microsoft Visual C++ Redistributable,很多编译好的 Python 依赖包需要这个运行库,最常见的就是 opencv-python、pycocotools。

解决:安装 Microsoft Visual C++ Redistributable 2015-2022 x64,装完重启终端。如果是 conda 环境,直接用conda install -c conda-forge opencv也能绕开一部分运行库问题。从 zip 包开始跑项目的人,十个里至少有三个卡在这里。

5.4 现象:GPU 利用率只有 40%,推理延迟不达标

原因:模型 forward 太快,瓶颈在数据加载和预处理。单张图推理时 batch_size=1,GPU 大部分时间在等待 CPU 把图准备好,这是把训练时的坏习惯带到推理阶段造成的。

解决:先量化预处理耗时。把图片解码、Resize、Normalize 放到多进程里做,推理阶段再把多张图拼成一个 batch,一次前向处理 4 到 8 张。如果 CPU 单线程推理已经达标,就不要为了 GPU 硬上 TensorRT,避免把系统搞成一个小黑匣子,出了问题难排查。

5.5 现象:一个产品上同时出现两种缺陷,分类模型必然翻车

原因:分类模型假设每张图只有一个标签。产线里划伤和脏污同时出现很常见,标注时你只能选一个主缺陷,模型推理时也只能输出一个,另一个漏检就会被算成误判。

解决:先统计真实场景里同一张图出现多缺陷的比例,超过 5% 就不要用单标签分类。切到多标签分类,输出层用 Sigmoid 加 BinaryCrossEntropy;如果还需要位置信息,直接切目标检测。这个决策要在项目一开始做进设计文档,别等模型上线了才发现标签体系不合理。

这些问题的共同特点是:模型结构和训练代码基本不用大改,修改数据分布和数据处理逻辑才是关键。指标不对的时候,先别急着换网络结构,把上面 5 条逐一核对。

6. 部署三板斧:ONNX 导出、阈值校准与流水线验证

分类模型训练完,模型文件只是第一步。真正上线前我会做三件事:导出 ONNX、校准 softmax 阈值、按批次做穿越测试。

先用 ONNX 固定推理框架:

model.cpu().eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, "defect.onnx", input_names=["input"], output_names=["prob"], dynamic_axes={"input": {0: "batch"}, "prob": {0: "batch"}}, opset_version=12, )

代码说明:dynamic_axes让 batch 维可变,产线上一次可以塞多张图。导出后先用 onnxruntime 跑一遍单张图,和 PyTorch 输出对比,误差小于 1e-5 再使用。

加载推理:

import onnxruntime as ort sess = ort.InferenceSession("defect.onnx", providers=["CPUExecutionProvider"]) probs = sess.run(["prob"], {"input": np.float32(x)})[0]

代码说明:CPU 推理用CPUExecutionProvider,有 GPU 就换成CUDAExecutionProvider。注意输入的数值类型必须是 float32,归一化参数要和训练时一致。

第二步是阈值校准。分类模型输出的概率不能默认按 0.5 切。缺陷检测的漏检代价远高于误杀代价,所以阈值应该向召回率倾斜:

from sklearn.metrics import precision_recall_curve valid = recall >= 0.995 threshold = thresholds[valid].max()

代码说明:先要求召回率不低于 99.5%,再在满足条件的阈值里取最大值,目的是保证漏检率可控的前提下,把误杀压到最低。这个阈值会直接写进产线检测配置。

第三步是批次穿越测试。按产品批次顺序取连续 10 批数据,模拟真实生产顺序跑一遍,看误检是不是集中在某一批。我最早用这个办法抓到过一次光源老化导致的亮度整体下降,训练集完全没覆盖,后来在增强里补了亮度抖动才解决。这个习惯让我明白了一件事:模型的泛化能力不只是靠结构,更靠数据分布里有没有覆盖现场的变化。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询