
简介这份资源面向希望入门工业视觉与异常检测的开发者提供一套基于无监督学习的缺陷检测Python实现核心思路是phase by transform可在缺少缺陷样本的情况下完成检测规避对大量标注数据的依赖。包内共13个文件以jpeg与png图像样本为主用于展示原始图、模糊图、重构图、二值化图及检测结果对比另含1个py脚本、1个ipynb笔记本、1份license与1份README说明压缩包约7.02MBnotebook可直接下载到本地运行。已有332人学习下载。读者可借此理解无监督缺陷检测的完整流程包括图像预处理、相位变换特征提取、重构与二值化判定并通过示例图直观对比各阶段效果适合作为深度学习异常检测的练手项目与排错参考。1. 无监督缺陷检测到底在做什么从产线误检率说起产线上最让人头疼的不是漏检是误检。划痕、反光、灰尘、油渍传统视觉方案靠阈值和模板匹配换个批次光照一变参数就得重调。更麻烦的是缺陷样本本身——良品成千上万缺陷可能几十张都凑不齐标注成本高到离谱。基于无监督学习的缺陷检测系统核心思路就是只用正常样本训练让模型学会正常长什么样推理时凡是偏离正常分布的区域就判为异常。这条路子适合谁适合缺陷样本稀缺、产品换型频繁、标注预算有限的工业质检场景。它不追求分类具体缺陷类型而是先解决有没有异常这个前置问题把人工复检的范围缩小到可疑区域。这套系统本质是一个可复现的工程框架包含数据组织、模型训练、阈值标定、推理部署几个环节不是单一算法文件。2. 无监督缺陷检测的模型选型与数据组织为什么不用分类网络2.1 三类主流方案的能力边界无监督缺陷检测落地时常见做法是分三大类基于重建的方法、基于特征嵌入的方法、基于生成模型的方法。重建类以自编码器为代表训练目标是让正常样本重建误差最小推理时异常区域重建误差大。它的优点是结构简单、训练快缺点是对细微缺陷敏感度不够容易把正常纹理波动也判成异常。特征嵌入类以 PatchCore、PaDiM 为代表用预训练骨干网络提取特征在正常样本的特征空间里建一个记忆库或高斯分布推理时算距离。这类方法在 MVTec AD 这类基准上表现稳定是目前工业落地的主流选择。生成类以 GAN 为基础训练不稳定调参成本高除非有特殊需求一般不建议新手直接上。选型时我一般会问三个问题缺陷尺寸占比多少纹理背景复杂不复杂推理延迟要求多少毫秒缺陷占比小于图像面积 1% 的优先考虑特征嵌入类因为重建类容易把微小缺陷重建掉。纹理重复度高的比如纺织、PCBPatchCore 的记忆库机制更稳。延迟要求 30ms 以内的骨干网络就不能选太深的ResNet18 往往比 WideResNet50 更实用。2.2 数据目录结构与预处理脚本无监督训练的数据组织有个硬性要求训练集只能放正常样本验证集和测试集才允许混入缺陷样本。目录结构建议按下面这样分后续脚本直接按路径读取不用改代码。dataset/ ├── train/ │ └── good/ # 只放正常样本 │ ├── 001.png │ ├── 002.png │ └── ... ├── val/ │ ├── good/ │ └── defect/ # 验证用缺陷样本 └── test/ ├── good/ └── defect/预处理脚本负责统一尺寸、归一化和数据增强。注意无监督场景下增强不能改变样本的正常性翻转、小角度旋转可以颜色抖动要谨慎否则模型会把颜色偏移学成正常变化。import cv2 import numpy as np import os from torchvision import transforms # 统一尺寸与归一化ImageNet 均值方差是预训练骨干的标配 preprocess transforms.Compose([ transforms.ToPILImage(), transforms.Resize((256, 256)), # 尺寸按骨干网络下采样倍数取整 transforms.ToTensor(), transforms.Normalize( # 与预训练权重对齐别自己乱改 mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ), ]) def load_and_save(src_dir, dst_dir): os.makedirs(dst_dir, exist_okTrue) for name in os.listdir(src_dir): img cv2.imread(os.path.join(src_dir, name)) if img is None: continue img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) tensor preprocess(img) # 保存为 npy 避免反复解码训练时直接内存映射 np.save(os.path.join(dst_dir, name.split(.)[0] .npy), tensor.numpy()) load_and_save(dataset/train/good, cache/train_good)这段脚本做了三件事把 BGR 转成 RGBOpenCV 默认 BGR直接送网络会颜色错位、按 ImageNet 统计量归一化、把张量存成 npy。参数上Resize 的尺寸必须是骨干网络下采样倍数的整数倍比如 ResNet 下采样 32 倍256 和 224 都行250 就会在特征图上产生对齐误差。归一化参数不要自己算数据集均值直接用预训练权重对应的统计量否则特征分布偏移记忆库距离阈值全乱。2.3 训练集划分的一个反直觉点很多人习惯把数据按 8:2 分训练和验证但无监督缺陷检测里验证集必须包含缺陷样本否则你没法标定阈值。正确做法是正常样本里拿 70% 做训练剩下 30% 正常样本加上全部缺陷样本做验证。缺陷样本数量少的话验证集里正常和缺陷比例可以到 1:1这样阈值扫描时两类样本都有足够统计量。我见过有人把缺陷样本也扔进训练集结果模型把缺陷当正常学了推理时一个异常都报不出来这种翻车现场在项目初期特别常见。3. 特征嵌入模型的训练与阈值标定从特征记忆库到异常分数3.1 PatchCore 的核心机制拆解PatchCore 的思路不复杂用预训练骨干网络对正常样本提特征把特征图切成 patch每个 patch 的特征向量存进一个记忆库。推理时对测试图的每个 patch 特征在记忆库里找最近邻距离就是异常分数。分数高的 patch 对应的原图区域就是异常区域。它的优势在于不需要训练整个网络只建记忆库所以训练时间短换产品线时重新提特征就行。但记忆库有个问题正常样本多了特征向量几十万个推理时逐个算最近邻太慢。所以 PatchCore 做了 coreset 采样从记忆库里挑一个代表性子集把库压缩到原来的 1% 到 10%推理速度就上来了。这个采样比例是个关键参数太小会漏掉正常模式的多样性太大会拖慢推理。我一般从 1% 开始试看验证集上的异常分数分布如果正常样本的分数上限和缺陷样本的分数下限有重叠就加大采样比例。3.2 特征提取与记忆库构建代码下面这段代码用 ResNet18 做骨干提取多层特征并拼接然后建记忆库。注意特征层选择浅层特征分辨率高但语义弱深层语义强但分辨率低工业缺陷往往尺寸小所以浅层和中层特征都要用。import torch import torch.nn as nn import torchvision.models as models import numpy as np from sklearn.random_projection import SparseRandomProjection class FeatureExtractor(nn.Module): def __init__(self): super().__init__() backbone models.resnet18(pretrainedTrue) # 取 layer1、layer2、layer3 三层输出兼顾分辨率与语义 self.stem nn.Sequential( backbone.conv1, backbone.bn1, backbone.relu, backbone.maxpool ) self.layer1 backbone.layer1 # 64 通道64x64 self.layer2 backbone.layer2 # 128 通道32x32 self.layer3 backbone.layer3 # 256 通道16x16 def forward(self, x): x self.stem(x) f1 self.layer1(x) f2 self.layer2(f1) f3 self.layer3(f2) # 上采样到同一分辨率后拼接 f2 nn.functional.interpolate(f2, sizef1.shape[-2:], modebilinear) f3 nn.functional.interpolate(f3, sizef1.shape[-2:], modebilinear) return torch.cat([f1, f2, f3], dim1) # 448 通道 def build_memory_bank(loader, extractor, devicecuda): extractor.eval().to(device) features [] with torch.no_grad(): for imgs, _ in loader: imgs imgs.to(device) feat extractor(imgs) # B x 448 x H x W B, C, H, W feat.shape feat feat.permute(0, 2, 3, 1).reshape(-1, C) # (B*H*W) x C features.append(feat.cpu().numpy()) features np.concatenate(features, axis0) # 随机投影降维减少距离计算量同时保留邻域结构 projector SparseRandomProjection(n_components128, random_state0) features projector.fit_transform(features) # coreset 采样这里用随机采样做示例生产环境建议用 k-center greedy idx np.random.choice(len(features), sizeint(len(features)*0.01), replaceFalse) return features[idx], projector逻辑说明FeatureExtractor把三层特征上采样到同一空间尺寸后拼接得到 448 维的 patch 特征。build_memory_bank遍历训练集把所有 patch 特征拉平用随机投影降到 128 维再随机采样 1% 作为记忆库。参数上n_components128是降维后的维度太小会丢信息太大降维没意义128 到 256 之间比较稳。采样比例 0.01 对应 1%如果正常样本纹理变化大这个值要往上调。注意随机投影的random_state要固定否则每次重建库的投影矩阵不同阈值没法复现。3.3 异常分数计算与阈值标定推理时对每个测试 patch 特征算它到记忆库的最近邻距离作为该 patch 的异常分数。整图的异常分数取 patch 分数的最大值或高分位数。阈值标定用验证集分别统计正常样本和缺陷样本的整图分数找一个阈值使误检率和漏检率达到业务可接受的水平。from sklearn.metrics import roc_auc_score import numpy as np def anomaly_score(feat, memory_bank): # feat: N x C, memory_bank: M x C # 用矩阵运算算欧氏距离避免逐样本循环 dist np.linalg.norm(feat[:, None, :] - memory_bank[None, :, :], axis2) return dist.min(axis1) # 每个 patch 的最近邻距离 def calibrate_threshold(val_scores_good, val_scores_defect): # 扫描阈值找使 F1 最大的点 all_scores np.concatenate([val_scores_good, val_scores_defect]) labels np.concatenate([np.zeros(len(val_scores_good)), np.ones(len(val_scores_defect))]) best_f1, best_th 0, 0 for th in np.linspace(all_scores.min(), all_scores.max(), 200): pred (all_scores th).astype(int) tp ((pred 1) (labels 1)).sum() fp ((pred 1) (labels 0)).sum() fn ((pred 0) (labels 1)).sum() precision tp / (tp fp 1e-8) recall tp / (tp fn 1e-8) f1 2 * precision * recall / (precision recall 1e-8) if f1 best_f1: best_f1, best_th f1, th return best_th, best_f1参数说明anomaly_score里用广播算距离矩阵内存占用是 N×M如果记忆库大要分批算。calibrate_threshold扫描 200 个阈值点找 F1 最大点。实际产线上F1 最大不一定是最优如果漏检代价远高于误检阈值要往低调宁可多报可疑区域让人工复检。这个权衡没有标准答案得跟质检部门对齐。4. 推理部署与可视化把异常分数落成可看的图4.1 热力图生成与缺陷区域定位异常分数是 patch 级的要落成原图上的热力图需要把 patch 分数上采样回原图分辨率再叠加到原图上。这一步直接影响质检员能不能快速定位缺陷。import cv2 import numpy as np def generate_heatmap(img_path, patch_scores, patch_grid(64, 64)): img cv2.imread(img_path) h, w img.shape[:2] # patch 分数 reshape 成网格 score_map patch_scores.reshape(patch_grid) # 归一化到 0-255 score_map (score_map - score_map.min()) / (score_map.max() - score_map.min() 1e-8) score_map (score_map * 255).astype(np.uint8) # 上采样到原图尺寸 heatmap cv2.resize(score_map, (w, h), interpolationcv2.INTER_CUBIC) heatmap cv2.applyColorMap(heatmap, cv2.COLORMAP_JET) # 叠加透明度 0.5 overlay cv2.addWeighted(img, 0.5, heatmap, 0.5, 0) return overlay逻辑说明patch_grid是特征图的空间尺寸ResNet18 在 256 输入下 layer1 输出是 64×64所以 patch 分数有 4096 个。reshape 成 64×64 后归一化再插值回原图尺寸。COLORMAP_JET是常用的热力图配色红色高分区对应异常。透明度 0.5 是经验值太透明看不清太实盖住原图纹理。注意插值方式用INTER_CUBICINTER_NEAREST会产生块状伪影质检员看着难受。4.2 推理性能优化从 200ms 到 30ms产线节拍往往要求单张推理 30ms 以内原始实现很容易跑到 200ms。优化点有三个骨干网络换更轻的、记忆库降维、距离计算用 GPU。骨干从 ResNet18 换 MobileNetV3特征维度从 448 降到 256推理时间能砍一半。记忆库从 128 维降到 64 维距离计算量再砍一半。距离计算从 numpy 换 torch 放 GPU 上矩阵运算并行度上来又能快一截。import torch def gpu_anomaly_score(feat, memory_bank, devicecuda): feat torch.from_numpy(feat).float().to(device) memory_bank torch.from_numpy(memory_bank).float().to(device) # cdist 批量算距离比广播省显存 dist torch.cdist(feat, memory_bank, p2) return dist.min(dim1)[0].cpu().numpy()参数说明torch.cdist的 p2 是欧氏距离比手动广播省显存。如果记忆库超过 10 万条cdist也会爆显存要分批算。实际部署时记忆库可以提前转成半精度 float16显存占用减半距离精度损失很小。5. 避坑与排查那些让误检率飙升的细节5.1 现象正常样本被大量判为异常误检率超过 30%原因通常是训练集里混入了少量缺陷样本或者正常样本的采集条件不一致不同光照、不同角度混在一起。模型学到的正常分布被污染了推理时把正常波动也当异常。解决方法是清洗训练集用聚类方法把特征空间里离群的样本挑出来人工确认确认是缺陷就移出训练集。另外采集条件要统一不同工位的图像不要混在一个训练集里。5.2 现象缺陷区域热力图偏移框不准原因是特征图的空间对齐没做好。骨干网络有下采样特征图上的一个点对应原图的一个感受野如果预处理时 Resize 的尺寸不是下采样倍数的整数倍特征图坐标和原图坐标就对不上。解决方法是 Resize 尺寸取 32 的整数倍并且记录预处理时的缩放比例热力图生成时按比例映射回去。另一个常见原因是多层特征拼接时上采样用了align_cornersFalse坐标偏移半个像素累积到原图就是几个像素的偏差。5.3 现象换产品线后模型完全失效原因是记忆库是在旧产品线的正常样本上建的新产品的正常纹理和旧产品差异大旧记忆库里的特征对新样本来说全是异常。解决方法不是重新训练整个模型而是只重建记忆库。骨干网络提取的是通用特征跨产品线往往还能用重建记忆库只要几分钟。这也是特征嵌入类方法比重建类方法更适合多产品线场景的原因。5.4 现象阈值在验证集上表现好上线后误检率翻倍原因是验证集和线上数据的分布不一致。验证集是从历史数据里抽的线上是实时采集的光照、温度、相机参数都可能漂移。解决方法是上线后持续收集正常样本的异常分数分布用滑动窗口动态调整阈值。如果分数分布整体上移说明正常样本的特征在漂移阈值要跟着上移。这个动态标定机制比固定阈值稳得多但要注意别把真正的缺陷也适应成正常。5.5 现象推理速度忽快忽慢偶尔卡顿原因是记忆库距离计算的内存分配不稳定或者 GPU 显存碎片化。解决方法是预分配显存把记忆库常驻 GPU不要每次推理都从 CPU 传。另外 Python 的 GIL 在多线程推理时会成为瓶颈用多进程或者把推理封装成 C 服务。如果用的是 TensorRT注意动态 shape 的配置固定输入尺寸能省掉不少开销。6. 进阶技巧用少量缺陷样本做阈值微调无监督不等于完全不用缺陷样本。验证集里的缺陷样本除了标定阈值还能做一件更有价值的事微调异常分数的映射函数。原始异常分数是最近邻距离它的数值范围跟特征维度、投影矩阵都相关跨产品线不可比。可以训练一个简单的逻辑回归或者保序回归把原始分数映射成 0 到 1 的异常概率这样不同产品线的阈值语义就统一了。from sklearn.isotonic import IsotonicRegression import numpy as np def fit_score_mapping(raw_scores_good, raw_scores_defect): # 保序回归单调映射不改变分数排序 X np.concatenate([raw_scores_good, raw_scores_defect]) y np.concatenate([np.zeros(len(raw_scores_good)), np.ones(len(raw_scores_defect))]) iso IsotonicRegression(out_of_boundsclip) iso.fit(X, y) return iso def predict_prob(iso, raw_score): return iso.predict([raw_score])[0]逻辑说明保序回归强制映射函数单调递增原始分数高的映射后概率也高不会出现分数高反而概率低的玄学。out_of_boundsclip处理超出训练范围的分数直接截断到边界值。这个映射函数只需要几十个缺陷样本就能拟合比重新训练模型划算得多。拟合完后阈值直接定在概率 0.5 或者业务要求的概率点上跨产品线不用重新标定。还有一个技巧是测试时增强TTA。对同一张测试图做翻转、小角度旋转分别推理后把异常分数平均。这样能降低单次推理的随机波动误检率通常能降几个百分点。代价是推理时间翻几倍适合离线复检场景不适合高节拍产线。从那以后我每次建完记忆库都强制走一遍验证集分数分布检查正常样本的分数上限和缺陷样本的分数下限必须拉开明显间隔间隔不够就回去调采样比例和特征层组合绝不带着重叠的分布上线。希望帮到你。本文还有配套的精品资源点击获取