简介:面向工业视觉缺陷检测场景的三星油污数据集,聚焦头发丝(TFS)与小黑点(XZW)两类典型瑕疵,共660张已标注图像,可直接用于YOLO、SSD等目标检测模型的训练与验证,适合算法工程师、科研人员及竞赛选手进行瑕疵识别与精度调优。压缩包共1324个文件,内含660张jpg原图与660个xml标注文件,标注信息完整,另有4个说明性txt文件,整体大小约314.54MB,图片命名清晰区分正常与异常样本,便于数据划分与快速加载;XML标注格式兼容VOC规范,可借助常用脚本转为COCO或YOLO格式,降低数据预处理门槛。已有1400余人学习下载,结合缺陷检测常见优化策略,可帮助读者理解油污缺陷数据特点、标注格式转换、数据增强及模型调优方法,是开展工业视觉检测实验和落地应用的实用数据基础。
1. 三星油污缺陷里的头发丝和小黑点:660张图为什么值得认真做
第一眼看到“三星油污缺陷类别:头发丝和小黑点,数据集大小:660张”这个数据集,多数人会觉得:只有两类缺陷、几百张图,分类有什么难的?但真正下过产线的工程师都清楚,这恰恰是典型的“看着简单、做起来翻车”的小样本工业图像数据集。油污没有固定边界,厚度、反光、附着形态都会让样本差异放大;头发丝是细线、小黑点是圆斑,两者同框时甚至像随机噪声。660张图每类平均只分到三百张左右,随便从头训练一个ResNet,训练集准确率能轻松跑到99%,一到现场换打光、换批次就原形毕露。这篇笔记按缺陷检测项目里最常用的一条路线,把任务边界、模型选型、数据增强、训练配置、数据清洗和上线前验证完整讲一遍,适合手里握着几百张缺陷图、想判断这个方向值不值得投入的算法和产线工程师。
2. 先想清楚任务再选模型:两类缺陷的信号差别决定了技术路线
2.1 头发丝和小黑点在图像特征上的本质差异
先别急着写训练脚本,把两类缺陷当成纯图像信号来看,差别比想象中大。头发丝是典型的线状缺陷,长宽比极端,可能达到30:1以上,在图中只占极少像素;它的主要特征是连续边缘和高频信息。小黑点则是近圆形斑点,面积往往比头发丝粗不少,但边缘经常是模糊的——油污在表面慢慢扩散,从暗到亮的过渡带会让模型很难界定边界。
这两类缺陷对卷积网络的考验完全不同。处理头发丝需要网络保留足够的分辨率,常规ResNet下采样到7×7的特征图时,一根头发丝可能只剩一个像素的响应,高层语义特征几乎丢失;而小黑点更依赖局部对比度,对分辨率没那么敏感,但对光照和表面底色敏感。如果你直接把图片缩到224×224就开训,等于默认放弃头发丝的高频信息。这个结论直接影响后边的输入尺寸选择:我一般会把训练分辨率拉到256甚至288,而不是套用ImageNet默认的224。
另一个容易忽略的点是,这个标题只给了“头发丝”和“小黑点”两个正类,并没有给OK类。实际应用里不可能每张图都恰好有缺陷,模型在OK样本上也会强行输出概率最高的类别。所以做方案时我通常建议把数据集扩展成三类:头发丝、小黑点、无缺陷/其他。哪怕当前训练集里没有OK图,也要尽量去产线上补一批,否则分类器上线后会把所有正常表面都判定成某一类缺陷。
2.2 为什么迁移学习是660张样本唯一靠谱的起点
从零训练一个ResNet,用660张图的规模,而不是660万张,基本必过拟合。卷积神经网络学到的低级特征,比如边缘、角点、条纹基元,天然适合描述头发丝这类线性纹理;但它的统计需要大量样本来支撑上百层的参数。660张图经过增强翻10倍也只有六千张,对深层网络来说是杯水车薪。
ImageNet预训练权重的价值在于:网络的浅层已经把边缘、颜色、纹理基元这些通用模式编码好了。用预训练权重初始化,再针对油污缺陷微调高层和分类头,相当于把“会看基本形状”的模型改造成“会认头发丝和黑点”的模型。我在工业缺陷项目里比较常用两种迁移策略。第一种是整体微调,backbone用1e-4量级学习率,分类头用1e-3;适合训练集画面风格和预训练数据差异较大的情况。第二种是只训练分类头、冻结backbone,也就是常说的linear probe,适合快速验证当前特征能不能区分两类缺陷;这个方案在MVTec AD这类工业基准上也验证过,即使只做线性分类,ImageNet特征通常也能提供可用的基线。
视觉Transformer在小数据集上的表现要谨慎看待。ViT的全局自注意力需要大数据量和大增强才能发挥效果,几百张图直接微调,注意力矩阵很容易退化成本地模板匹配,效果反而比不上同样规模的CNN。660张图的现实决定了CNN加迁移学习仍然是性价比最高的起点。
2.3 模型选型对比:ResNet、EfficientNet 与 ViT 的取舍
| 模型 | 参数量 | 适合分辨率 | 小样本表现预期 | 部署成本 |
|---|---|---|---|---|
| ResNet18/34 | 11M/21M | 256~288 | 强,配合预训练收敛平稳 | 低,CPU或边缘盒子可跑 |
| EfficientNet-B0 | 5M | 256~288 | 中,需要EMA、强增强等辅助技巧 | 低,但要调的细节多 |
| MobileNetV3 | 2.5M | 256 | 中,分类头容量偏小 | 很低,适合嵌入式 |
| ViT-Small | 22M | 224~384 | 弱,数据量不够时难收敛 | 中,推理库要求更高 |
综合经验,我一般直接选ResNet34,不选ResNet50。原因是50层对几百张图的分类任务来说容量过大,更容易把增强后的细节当作重要特征记住;ResNet34的容量与头发丝、黑点这类浅层视觉特征更匹配,训练也更快。输入层之后的第一个conv不需要改,分类头改成两层全连接,中间加Dropout。如果后续发现过拟合还没压住,可以把最后三个残差块里的BatchNorm冻结,只让分类头更新,这个技巧在后面避坑章节会展开。
EfficientNet在这个数据集上不是首选。它的训练技巧相当敏感,比如EMA参数、随机深度、AutoAugment强度,任何一个没调好,效果就比ResNet差不少。660张图也撑不起这些增强带来的正则化收益。另外经常有人问,是不是可以直接把图片切成patch后做检测,比如套用YOLOv8训练自己的数据集那套流程?这里我再替大家踩一脚刹车:这是图像级分类任务,不是目标检测。660张图去画目标框,标注成本直接翻几倍,而且头发丝这种细长目标对检测框回归很不友好。先用分类思路拿到可用基线,如果确实需要定位缺陷,再考虑后续扩展。
3. 用PyTorch把基线跑通:目录加载、数据增强与训练配置
3.1 数据集目录规范与ImageFolder加载:先从文件命名开始
拿到这套三星油污缺陷数据集,第一步是确认目录结构。PyTorch的ImageFolder要求数据按“根目录/类别/图片文件”组织,我一般会整理成下面这样:
dataset/ hair/ # 头发丝样本 spot/ # 小黑点样本 ok/ # 无缺陷样本(如果补齐了)如果你的原始目录不是这个结构,先写一个Python脚本重命名和归类。注意ImageFolder按字母序分配类别索引:hair是0,ok是1,spot是2。如果后续要导出ONNX部署,label顺序千万不要在中间改动,否则模型输出的每一个数字都错了。
from torchvision.datasets import ImageFolder train_ds = ImageFolder(root="dataset/train", transform=train_transform) val_ds = ImageFolder(root="dataset/val", transform=val_transform) # 打印类别映射,确认与标注一致 print(train_ds.class_to_idx) # 返回 {'hair': 0, 'ok': 1, 'spot': 2}这段代码的关键是验证class_to_idx。实际项目里常见的低级错误是:有人手工把标签拼进文件名里,比如hair_001.jpg、spot_002.jpg,然后直接按文件名写自定义Dataset;这种做法也能跑,但边界情况特别多,一旦某个文件名不规则就整个报错。ImageFolder的好处是天然按文件夹分配标签,且每个类别的样本数可以通过遍历很容易统计。加载之后,建议顺手检查每个类别的数量,660张图里两类数量很可能不平衡,这会直接影响后边损失函数的设计。
3.2 增强策略:针对线状与点状缺陷的transform组合
缺陷分类的增强策略,和通用的ImageNet分类不一样。通用的RandomResizedCrop默认scale取值范围是(0.08, 1.0),意思是裁剪区域可以缩小到原图的8%,这对缺陷图是灾难。如果把一个只有2%图像面积的头发丝裁掉了,留下的全是背景表面,模型学到的就不是缺陷本身。所以增强要克制,所有操作以“保留缺陷的原始形态”为前提。
from torchvision import transforms as T train_transform = T.Compose([ T.Resize((288, 288)), # 先放大一点,给裁剪留空间 T.RandomResizedCrop(size=(256, 256), scale=(0.7, 1.0), # 最小裁原图70%,防止缺陷被裁掉 ratio=(0.9, 1.1)), # 长宽比扰动小一些 T.RandomHorizontalFlip(p=0.5), T.RandomVerticalFlip(p=0.3), T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1, hue=0.0), # 不动色相,油污颜色有物理含义 T.RandomRotation(degrees=15), # 头发丝方向如果随机,小角度旋转够用 T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = T.Compose([ T.Resize((256, 256)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])这里逐项说明参数的选择逻辑。Resize到288再随机裁剪到256,等价于做了一点尺度扰动,同时避免直接使用默认的RandomResizedCrop把细节丢失。ColorJitter里我把hue设成0,因为油污颜色本身是分类的重要线索,如果允许色相大幅偏移,模型会被迫忽略颜色信息,对暗色黑点和透明油污都更不敏感。RandomRotation只给了15度,考虑到产线可能固定摄像头角度,超过30度的旋转会引入现实里不存在的形态,最后模型对真实旋转样本反而不稳。验证集不要用任何随机增强,只用Resize和Normalize,这样才能保证评估结果可复现。
3.3 训练配置与评估指标:小样本下的损失函数选择
训练配置这块,我先说结论:用预训练ResNet34,优化器用AdamW,backbone学习率1e-4,分类头学习率1e-3,训练30到40个epoch,损失函数用Focal Loss或带类别权重的交叉熵。下面是一段可以直接跑的简化训练逻辑。
import torch import torch.nn as nn from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR class FocalLoss(nn.Module): def __init__(self, gamma=1.0, alpha=None): super().__init__() self.gamma = gamma self.alpha = alpha def forward(self, logits, targets): ce = nn.functional.cross_entropy(logits, targets, reduction="none") pt = torch.exp(-ce) loss = (1 - pt) ** self.gamma * ce if self.alpha is not None: alpha_t = self.alpha[targets] loss = alpha_t * loss return loss.mean() model = torchvision.models.resnet34(weights="IMAGENET1K_V1") model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(512, 3) ) param_groups = [ {"params": model.conv1.parameters(), "lr": 1e-4}, {"params": model.layer1.parameters(), "lr": 1e-4}, {"params": model.layer2.parameters(), "lr": 1e-4}, {"params": model.layer3.parameters(), "lr": 1e-4}, {"params": model.layer4.parameters(), "lr": 1e-4}, {"params": model.fc.parameters(), "lr": 1e-3}, ] optimizer = AdamW(param_groups, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=35)Focal Loss在这里的gamma我设成1.0,而不是目标检测里常用的2.0。因为缺陷分类的类别数量少,太难样本的比例不高,gamma过大会让模型过度关注那些标注本身就有争议的模糊样本,产生反作用。如果类别不平衡比较严重,比如头发丝有400张、黑点只有200张,在alpha里按类别频率反比设置权重;如果大致均衡,alpha可以不设。
评估指标不要只看准确率。缺陷场景里漏检代价远大于误检,所以要看每类的召回率和精确率,尤其要单独观察hair这一类的召回率。我会把confusion matrix打印出来,重点检查hair和spot之间是否互相错认,以及两类缺陷是否都被分到了ok类。错误类型不同,后续要调的参数完全不同:hair被误判成spot,说明模型学到的形状信息不足,需要增加分辨率或调整输入预处理;hair被误判成ok,说明缺陷信号太弱,需要从增强强度和损失函数权重上找原因。
4. 数据比模型更值得花时间:清洗、均衡与验证集划分
4.1 给660张图做一次“肉眼体检”:过曝、欠曝与重复样本
很多工程师拿到数据集后直接进训练脚本,其实先花半天把所有图快速翻一遍,收益比调十次学习率都大。油污缺陷在采集过程中经常伴随光源反射、表面划痕、脏点等干扰,这些干扰可能让标签本身就不干净。我用下面这段代码统计每张图的基本质量指标,然后人工复核排名靠前的异常样本。
import cv2 import numpy as np import glob paths = glob.glob("dataset/train/*/*.jpg") rows = [] for p in paths: img = cv2.imread(p, cv2.IMREAD_GRAYSCALE) if img is None: rows.append((p, 0, 0, 0, 0)) continue h, w = img.shape mean_intensity = img.mean() contrast = img.std() laplacian_var = cv2.Laplacian(img, cv2.CV_64F).var() rows.append((p, h * w, mean_intensity, contrast, laplacian_var)) # 按对比度升序看,最容易找到欠曝、失焦或重复样本 for item in sorted(rows, key=lambda x: x[3])[:30]: print(item)代码的思路是同时计算亮度均值、对比度标准差和拉普拉斯方差。亮度均值接近0或255,大概率过曝或欠曝;对比度标准差很低,说明画面几乎是一个灰面,缺陷可能肉眼不可见;拉普拉斯方差低,则提示图像失焦或压缩过度。这三类图混进训练集,会干扰BatchNorm统计,让模型误以为“暗黑背景”也是缺陷的一部分。我的原则是保留边缘情况,但单独拎出来放到验证集,不参与训练;如果某类只有一两张极端样本,直接删除更省事。
4.2 类别不平衡与分组拆分:不能按图随机切分
660张图做训练/验证/测试拆分时,最容易犯的一个错误是直接对文件列表随机打乱。工业缺陷数据集的特殊性在于:同一个缺陷可能连续拍了好几张图,甚至同一片油污上的不同视角也都被放进数据集。如果这些同源样本同时出现在训练集和验证集,模型相当于偷看了答案,验证分数会虚高到没有任何参考价值。
常见做法是给每个样本标注一个group_id,然后按组拆分。比如文件名是hair_20241011_03_001.jpg,可以通过前缀提取拍摄批次。
from sklearn.model_selection import GroupShuffleSplit df["group_id"] = df["image_path"].str.extract(r"(hair_\d+|spot_\d+)") gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(df, groups=df["group_id"]))如果原始数据集没有提供批次信息,就根据文件名前缀、采集尼康时间或者目录层级推断。实在无法分组时,宁可把验证集比例降到15%,也不要让同源样本穿透。我这里习惯是训练、验证、测试按6:2:2切,但测试集只在最终确认方案时用,日常调参只看验证集。660张的规模下,更好的做法是5折交叉验证:每折80%训练、20%验证,最后报告五折的平均F1。交叉验证能显著降低小数据集上单次切分运气的影响,代价是训练时间乘以五,但这点投入完全值得。
4.3 复制粘贴增强会翻车:合成样本的边界与泊松融合
数据不够时,最容易想到的办法就是从真实缺陷图上抠出缺陷,粘贴到无缺陷表面上去合成新样本。这个方向可行,但直接硬切一定翻车。原因是缺陷与背景的边界会形成一条明显的拼接缝,模型很快学到“只要边缘有颜色突变就是缺陷”,等上线之后,真实油污边缘是渐变的,反而识别不出来。
我常用的做法是对缺陷mask做高斯模糊软化边界,或者直接使用泊松融合让粘贴区域的梯度自然过渡。下面是一个简化版本:
def paste_defect(background, defect_img, mask, center): mask_soft = cv2.GaussianBlur(mask, (15, 15), 5) mask_3 = cv2.merge([mask_soft] * 3) roi = background[center[0]-h//2:center[0]+h//2, center[1]-w//2:center[1]+w//2] blended = (defect_img * mask_3 + roi * (1 - mask_3)) background[center[0]-h//2:center[0]+h//2, center[1]-w//2:center[1]+w//2] = blended.astype(np.uint8) return background这里mask_soft就是关键参数。高斯核越大,过渡越自然,但缺陷边缘也越模糊;15×15、sigma=5对油污这种本身边缘不锐利的缺陷比较合适。合成样本还要注意不要只贴到图像中央,位置分布要接近真实采集时的出现概率;另外粘贴的缺陷数量也不要超过两个,否则模型可能学成“图像里必须有缺陷区域”。说到底,合成数据只是补充,先保证660张真实样本的标签质量,再谈生成更多数据。
5. 避坑与常见问题排查:油污分类最容易踩的五个坑
5.1 反光让油污“隐身”或“变形”的坑
现象:训练集里模型F1达到0.95,拿到现场灯光下一测,误检率突然飙升,很多正常表面被判成油污,部分真实缺陷反而漏掉。原因:油污本身有厚度和光泽,在不同角度打光下会形成高亮反光带;训练集里的反光样本太少,模型没有见过“亮斑形态的缺陷”。解决:在增强里加入亮度和对比度扰动,尤其是局部Gamma扰动;同时建议收集多个光照角度下拍摄的同一缺陷图像,把光照变化做成一个专门的验证子集,用来衡量模型的真实鲁棒性。如果现场确实只有单光源环境,最省事的办法是在分类前提做一次简单的光照归一化。
5.2 同源样本泄露导致的虚高指标
现象:验证集F1达到0.99,模型下载到边缘设备后表现断崖式下跌。原因:训练集和验证集存在同源样本,可能是同一片油污的两张连拍图,也可能是同一缺陷不同裁切;随机拆分时它们被分到了两侧。解决:按缺陷实例分组拆分,而不是按文件名随机拆分。如果发现当前数据集已经拆完了,无法回溯,就用图像哈希做近似去重,把相似度高于阈值的图全部放进同一组;我给这个坑写的教训是:小数据集上,验证分数越高越要警惕,先检查泄露再做任何模型改进。
5.3 BatchNorm在训练和验证时的统计漂移
现象:训练loss稳定下降,但验证准确率每几个epoch剧烈波动,甚至训练集本身的准确率也来回跳。原因:batch_size被显存限制在8甚至4,BatchNorm在小batch上估计均值和方差的噪声很大,running_stats没有代表性的统计值。解决:优先把batch_size提到32以上;实在提不上去,把骨干网络部分的BatchNorm层替换成GroupNorm,或者冻结前几个residual block的BN参数。工业数据集由于图像分辨率高,显存紧张时很容易犯这个错,属于典型的硬件限制导致的参数玄学。
5.4 标注噪声集中在边界样本上
现象:训练到后期,模型总在个别样本上反复波动,loss降不下来。人工复查发现这些样本有的其实看不出明显缺陷,有的是严重划痕而被标成头发丝。原因:人工标注按“像不像”来判断,油污和划痕在颜色、形态上天然重合。解决:先用置信学习或直接用训练好的模型找出预测概率接近0.5的样本,拉出来人工复审;训练时开启label smoothing 0.05,让模型不至于对错误标签过度自信。这一步在660张规模下收益非常明显,因为几个噪声样本就能影响一整个类的决策边界。
5.5 增强强度过大,反而把缺陷“洗”没了
现象:加了RandAugment、Cutout之后,验证F1不但没涨,反而从0.90降到0.82。原因:头发丝只占图像极小面积,Cutout随便挖掉一个区域就可能把整根头发丝清除;RandAugment里的旋转、缩放组合也可能让细线缺陷变形到无法辨认。解决:对这类小目标缺陷,增强强度要降级使用,把线性缺陷的形态约束当成先验。我的建议是:先用没有任何随机增强的baseline跑一版,再逐步增加增强项,每次只加一项,观察验证集F1和误报率变化,不要一次性堆满全套增强。
6. 从“看起来准”到“敢上线”:难样本回溯、模型导出与现场验证
6.1 建一个专门的难样本回归集
模型训练完成不等于项目结束。我通常会在训练集之外留下一个“难样本集”,里面的图全部来自训练过程中验证集分类错误的样本、现场反馈的误报样本、以及人工觉得普通工程师都很难判断的模糊图。每次调整模型后,都先跑这组回归集,再跑标准验证集。原因很简单:小样本模型很容易在修复一个问题的同时破坏掉之前已经学会的能力,回归集就是给模型准备的后悔药。难样本集不需要大,20张到30张足够;但每张图必须有明确的正确标签。
6.2 ONNX导出与推理时保持预处理一致
模型要部署到产线环境,最常见的方式是导出ONNX,用ONNX Runtime或OpenVINO推理。这里最大的坑不是导出本身,而是推理时的预处理必须与训练时完全一致,包括Resize的插值方式、是否归一化、通道顺序。下面是一段导出参考。
import torch model.eval() dummy_input = torch.randn(1, 3, 256, 256) torch.onnx.export( model, dummy_input, "hair_spot.onnx", opset_version=12, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, )导出后建议先用ONNX Runtime加载,对同一张图分别走PyTorch和ONNX推理,对比输出结果是否一致,差异超过1e-3就要查找是哪一步预处理没对齐。部署时我还习惯把阈值设置在0.7而不是0.5:预测概率低于阈值时,把结果标记为“不确定”,让产线人工复核,而不是直接归类到某一缺陷。660张图训练的模型再怎么调优,也必然存在没见过的表面形态,给模型一个拒绝回答的选项,比强行输出分类结果要可靠得多。这也是我做工业质检这几年最深的教训:线上稳定性靠的不是更聪明的网络结构,而是数据边界和推理时的保守策略,希望帮到你。
本文还有配套的精品资源,点击获取