简介:这份资源面向希望入门工业视觉缺陷检测的开发者与算法学习者,提供一套基于无监督学习的缺陷检测Python实现方案。核心思路借助相位变换(phase by transform)完成图像重构与异常判别,无需大量缺陷样本即可定位产品表面异常,适合数据稀缺场景下的快速验证与教学演示。压缩包共13个文件,约7.02MB,包含1个py脚本与1个ipynb笔记本作为主要代码载体,另有5个jpeg、4个png图像用于展示原始图、模糊图、重构图、二值化图及检测结果对比,并附README说明与LICENSE授权文件,便于直接下载到本地运行调试。目前已有332人学习下载。读者可从中获得完整的无监督缺陷检测流程、相位变换重构思路、图像预处理与结果可视化方法,以及可复用的notebook实验模板,适合作为深度学习缺陷检测方向的入门参考与二次开发起点。
1. 产线上没有缺陷样本,怎么把无监督学习跑成一套缺陷检测系统
做工业视觉的同行大概率都遇到过这种局面:客户拉来一条新产线,说“帮我做缺陷检测”,然后你问要缺陷样本,对方两手一摊——良品堆成山,坏品一个没有。这不是刁难,是真实产线的常态。缺陷本身是低概率事件,等攒够几百张标注好的缺陷图,产线早就换型了。基于无监督学习的缺陷检测系统要解决的就是这个死结:只用良品图训练,让模型学会“正常长什么样”,任何偏离正常的区域都判为异常。它适合两类人——一类是被缺陷样本卡住、标注成本压垮的算法工程师,另一类是手里有大量良品图、想快速上线第一版检测能力的产线技术负责人。这篇文章不讲论文综述,只讲怎么从零把一套能跑、能调、能上线的无监督缺陷检测系统搭起来,包括选型、训练、阈值标定和那些让我翻过车的坑。
2. 无监督缺陷检测到底在学什么:三条主流路线与选型理由
2.1 重建式、嵌入式、合成式,先搞清楚你该站哪一队
无监督缺陷检测不是一种算法,而是一类思路。落到工程上,主流就三条路线,选错了后面全是白干。
第一条是重建式,代表是自编码器和它的变体。核心逻辑是:拿良品图训练一个网络,让它学会压缩再还原良品图;推理时喂进一张有缺陷的图,缺陷区域因为训练时没见过,重建误差会明显偏高,误差图就是缺陷热力图。这条路线的优点是直观、可解释,缺点是网络太强会把缺陷也“重建”出来,太弱又对正常纹理还原不好,平衡点很难找。
第二条是嵌入式,代表是 PatchCore、PaDiM 这类方法。思路是借一个在 ImageNet 上预训练好的骨干网络(ResNet、WideResNet 都行)提取特征,把良品的特征块存成一个记忆库;推理时算每个位置特征和记忆库的最近邻距离,距离大就是异常。这条路线的工程优势非常明显:不需要训练整个网络,特征提取器冻结,只需要构建和查询记忆库,落地速度快,对小样本极其友好。我目前手上大部分项目默认走这条。
第三条是合成式,用 GAN 或者扩散模型生成伪缺陷,把无监督硬掰成有监督。优点是能利用判别式模型的高精度,缺点是生成质量不稳定,训练成本高,调参玄学成分大,产线赶工期的时候不建议碰。
选型判断其实就三个问题:良品图有多少张?缺陷类型是否已知?上线时间给多久?良品图少于 200 张、缺陷类型未知、两周内要出 demo,直接选嵌入式;良品图上千张、缺陷偏结构性(比如缺件、错位),可以试重建式;合成式留给研究场景。
2.2 嵌入式方案的最小可跑通流程
下面这段代码用 PyTorch 搭一个嵌入式检测的最小骨架,骨干用预训练 ResNet18,特征层取 layer2 和 layer3 拼接,这是 PatchCore 的简化版思路。先跑通,再谈优化。
import torch import torch.nn as nn import torchvision.models as models import torchvision.transforms as T from torch.nn import functional as F class FeatureExtractor(nn.Module): def __init__(self): super().__init__() # 用 ImageNet 预训练权重,冻结全部参数 backbone = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) self.stem = nn.Sequential( backbone.conv1, backbone.bn1, backbone.relu, backbone.maxpool, backbone.layer1, backbone.layer2 ) self.layer3 = backbone.layer3 for p in self.parameters(): p.requires_grad = False # 关键:冻结,不训练 def forward(self, x): f2 = self.stem(x) # 1/8 分辨率 f3 = self.layer3(f2) # 1/16 分辨率 # 把 f3 上采样到 f2 尺寸后拼接,兼顾语义和细节 f3_up = F.interpolate(f3, size=f2.shape[-2:], mode="bilinear", align_corners=False) return torch.cat([f2, f3_up], dim=1) # 特征图每个空间位置做自适应平均池化,得到 patch 级特征 def embed_patches(feat_map, patch_size=3): # feat_map: [B, C, H, W] -> [B, H*W, C] pooled = F.avg_pool2d(feat_map, kernel_size=patch_size, stride=1, padding=patch_size//2) B, C, H, W = pooled.shape return pooled.permute(0, 2, 3, 1).reshape(B, H * W, C)这段代码有三个参数决定成败。patch_size控制感受野,设 3 意味着每个特征点看周围 3x3 的区域,纹理细的产线(比如布面、PCB)调到 3 或 5,结构大的(比如铸件)可以到 7。特征层选择上,layer2 分辨率高、细节多但语义弱,layer3 语义强但位置粗,拼接是折中方案;如果缺陷极小(几个像素),只保留 layer2 反而更稳。冻结参数这一行千万别漏,一旦解冻,小数据集上几个 epoch 就把预训练特征带偏了,这是新手最常见的翻车点。
2.3 记忆库构建与推理距离计算
特征提出来之后,嵌入式方案的核心是构建良品记忆库,推理时算最近邻距离。全量存所有 patch 特征会爆内存,工业上一般做 coreset 采样,保留最具代表性的那部分。
import numpy as np from sklearn.random_projection import SparseRandomProjection class MemoryBank: def __init__(self, n_proj=128, coreset_ratio=0.1): self.proj = SparseRandomProjection(n_components=n_proj, random_state=0) self.coreset_ratio = coreset_ratio self.bank = None def fit(self, features): # features: [N, C] 所有良品 patch 特征 reduced = self.proj.fit_transform(features) # 降维,加速最近邻 n_keep = max(1, int(len(reduced) * self.coreset_ratio)) # 贪心最远点采样,让保留的特征尽量分散 idx = self._greedy_coreset(reduced, n_keep) self.bank = torch.from_numpy(reduced[idx]).float() return self def _greedy_coreset(self, X, k): n = len(X) selected = [np.random.randint(n)] dist = np.linalg.norm(X - X[selected[0]], axis=1) for _ in range(k - 1): nxt = int(np.argmax(dist)) selected.append(nxt) dist = np.minimum(dist, np.linalg.norm(X - X[nxt], axis=1)) return np.array(selected) def score(self, query_features): # 返回每个 query patch 到记忆库的最近邻距离 q = torch.from_numpy(self.proj.transform(query_features)).float() d = torch.cdist(q, self.bank) return d.min(dim=1).values.numpy()n_proj=128是降维维度,特征原始通道数可能是 384 或 512,降到 128 能把最近邻查询速度提三到五倍,精度损失通常在 1% 以内。coreset_ratio=0.1表示只保留 10% 的良品特征,一张 512x512 的图大概产生几千个 patch,一万张良品图全存是几千万条,采样后压到几百万条,内存和查询都能接受。贪心最远点采样是 O(Nk),N 大时慢,工程上可以先用随机采样粗筛再跑贪心,或者直接用 k-center 的近似算法。推理时把距离图 resize 回原图尺寸,就是缺陷热力图。
3. 从良品图到上线:数据、训练、阈值标定的完整落地链路
3.1 数据采集与预处理:良品图不是随便拍
无监督方法对良品图的质量要求比有监督还高,因为模型只见过这些图,你喂什么它学什么。采集阶段有几条硬规矩。第一,良品图必须覆盖产线的正常波动:不同批次、不同光照、不同工位、不同季节的色差都要有,否则模型会把正常波动当异常。我一般要求至少覆盖连续三天的生产数据,每天抽 200 到 500 张。第二,图像要配准。相机固定的话问题不大,但如果工件在视野里位置会漂,必须先做模板匹配或特征点对齐,把工件摆正再送进模型,否则模型学到的“正常”里混进了位置变化,异常定位会糊成一片。第三,分辨率要统一,长边缩到 512 或 768 是常见做法,太小丢细节,太大推理慢。
预处理流水线用 torchvision 写清楚,别在训练和推理时用两套逻辑,这是低级但高频的坑。
train_tf = T.Compose([ T.Resize((512, 512)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) # 推理时只做 Resize + ToTensor + Normalize,绝对不要加随机增强注意推理流水线里不能有任何随机性,RandomHorizontalFlip、ColorJitter 这类只在训练特征提取时用,而且用了增强之后记忆库要重新构建。归一化参数用 ImageNet 的均值方差,因为骨干是 ImageNet 预训练的,这一步对齐能明显提升特征质量。
3.2 训练与记忆库构建的实操命令
嵌入式方案严格说没有“训练”,只有特征提取和记忆库构建。但工程上我会把它包装成一个脚本,方便复现和版本管理。
# 目录结构约定 # data/good/ 良品图 # data/test/ 测试图(含缺陷) # checkpoints/bank.pkl 记忆库输出 python build_bank.py \ --good_dir data/good \ --backbone resnet18 \ --layers layer2,layer3 \ --patch_size 3 \ --n_proj 128 \ --coreset_ratio 0.1 \ --batch_size 16 \ --out checkpoints/bank.pkl--layers指定用哪几层特征,多一层拼接精度可能涨一点但内存和耗时也涨。--batch_size在显存够的前提下尽量大,特征提取是纯前向,batch 16 在 8G 显存上跑 512 分辨率没问题。构建完记忆库后,拿测试集跑一遍,输出每张图的异常分数和热力图,人工看几张确认定位是否合理,再进入阈值标定。
3.3 阈值标定:决定误报和漏报的那条线
无监督检测没有天然的判别阈值,异常分数是个连续值,卡在哪直接决定误报率和漏报率。产线最怕的是误报,一天报几百次假警,操作员直接把系统关了。我的做法是:拿一批确认的良品图跑推理,统计异常分数的分布,取 99.5 或 99.9 分位数作为阈值,保证良品误报率控制在千分之几;再拿已知缺陷图验证漏报,如果漏报太高,说明特征或 patch_size 需要调,而不是简单降阈值。
import numpy as np def calibrate_threshold(scores_good, scores_defect, target_fpr=0.005): # scores_good: 良品图的图像级异常分数 th = np.quantile(scores_good, 1 - target_fpr) recall = (scores_defect > th).mean() print(f"阈值={th:.4f} 误报率<={target_fpr} 缺陷召回={recall:.3f}") return thtarget_fpr是你能接受的误报率上限,产线通常 0.005 到 0.01。如果算出来的召回低于 0.8,别急着调阈值,先回去看热力图——大概率是缺陷太小被 patch 平均掉了,或者特征层选得太深。阈值标定不是一劳永逸,换批次、换光源、换镜头都要重新标,我一般把标定脚本做成定期任务,每周用最新良品数据刷一次。
4. 避坑与排查:那些让无监督检测翻车的真实记录
4.1 良品图里混进了缺陷,模型直接学歪
现象:上线后模型对某类缺陷完全不报,热力图一片干净。原因:采集良品图时靠人工挑,漏掉了少量带轻微缺陷的图,模型把这些缺陷也当成“正常”学进了记忆库。解决:良品图入库前跑一遍粗筛,用第一版模型对候选良品图打分,分数偏高的挑出来人工复核,确认是良品再入库。这个自举过程跑两三轮,记忆库会干净很多。
4.2 光照变化被当成缺陷,误报刷屏
现象:白天不报,晚上换班后误报激增。原因:训练良品图集中在白天采集,夜间光照色温和亮度不同,特征分布偏移,模型把光照变化判成异常。解决:良品图必须覆盖不同时段和光照条件;如果补不齐数据,在预处理里加光照归一化,比如灰度世界白平衡或者 CLAHE,把光照影响压下去再提特征。
4.3 patch_size 设太小,缺陷被切碎定位不准
现象:缺陷能报出来,但热力图是零散的点,连不成完整区域,后处理很难框出缺陷。原因:patch_size 太小,每个特征点感受野不够,缺陷区域内部特征差异被放大。解决:把 patch_size 从 3 调到 5 或 7,或者对异常分数图做高斯平滑再二值化。平滑核大小一般取 patch_size 的 2 到 3 倍。
4.4 记忆库太大,推理延迟拖垮产线节拍
现象:离线测试精度很好,上线后单张推理要 800ms,产线节拍要求 200ms。原因:coreset_ratio 设太高,记忆库几十万条,最近邻查询是瓶颈。解决:降 coreset_ratio 到 0.05 甚至 0.02,同时把 n_proj 降到 64;再不行就上 FAISS 做近似最近邻,用 IVF 索引把查询压到毫秒级。精度会掉一点,但节拍保住了。
4.5 用测试集调阈值,上线就崩
现象:离线评估 AUC 0.98,上线一周误报率远超预期。原因:阈值是在测试集上调的,测试集和产线真实分布有差异,过拟合了测试集。解决:单独留一批产线实时数据做阈值标定,测试集只用来评估模型能力,不参与任何阈值决策。这条血泪经验值一个项目。
5. 把无监督检测用稳的进阶技巧:多尺度融合与在线更新
单尺度特征在复杂缺陷上容易顾此失彼,小缺陷要浅层高分辨率特征,大缺陷要深层语义特征。我现在的默认配置是多尺度融合:layer1、layer2、layer3 都提,各自构建记忆库,推理时把三个尺度的异常分数图归一化后加权求和,权重按验证集表现调,通常浅层权重 0.3、中层 0.4、深层 0.3。这样小缺陷靠浅层兜住,大缺陷靠深层兜住,实测比单尺度召回能涨 5 到 10 个点。
def fuse_multiscale(score_maps, weights=(0.3, 0.4, 0.3)): # score_maps: 三个尺度的异常分数图,已 resize 到同一尺寸 normed = [] for s in score_maps: s = (s - s.min()) / (s.max() - s.min() + 1e-8) normed.append(s) return sum(w * s for w, s in zip(weights, normed))另一个技巧是在线更新记忆库。产线运行过程中会不断产生新的良品图,这些图代表了最新的正常分布。我一般每周挑一批低异常分数的实时图,人工确认后增量加入记忆库,同时按时间衰减淘汰最老的样本。这样模型能跟上产线的缓慢漂移,不用频繁重训。更新时注意保留原始记忆库的一部分做锚点,防止新数据把分布带偏。
验证方法上,别只看 AUC。产线关心的是固定误报率下的召回,以及单张推理延迟。我习惯画一张误报率-召回曲线,标出产线可接受的误报率位置,看召回够不够;再压测 1000 张图统计 P99 延迟。这两个数达标,方案才算能上线。
最后说个我自己的习惯:每上一个新产线,先花半天把良品图按批次、时段、工位分组,每组抽几张跑推理看分数分布,分布偏移大的组单独处理。这个动作看起来笨,但能提前暴露八成以上的上线翻车。无监督检测没有后悔药,数据阶段偷的懒,上线后都会加倍还回来。希望帮到你。
本文还有配套的精品资源,点击获取