工业质检无监督异常检测:PatchCore与MVTec AD实战指南 1. 工业质检场景下无监督异常检测的选型逻辑1.1 为什么工业质检需要无监督异常检测在工业质检这个领域摸爬滚打过几年的人都有一个共识最难的从来不是模型结构而是数据本身。一条产线每天可能产出几万件产品其中真正有缺陷的可能只有几十件甚至几件而且缺陷形态千奇百怪——划痕、凹陷、脏污、缺料、错位每一种缺陷的样本数量都少得可怜。你如果按照传统监督学习那套思路去做先收集大量缺陷样本再标注再训练基本等于给自己挖坑。我见过太多团队在这个环节翻车。有个做金属表面检测的朋友花了三个月时间收集了两千张缺陷图标注完之后训练了一个ResNet分类器离线测试准确率能到98%结果一上产线就崩了——因为产线上出现了训练集中从未见过的新型缺陷模型直接把它判成了合格品。这就是监督学习在工业质检场景下的致命伤你只能检测出你见过并标注过的缺陷类型。无监督异常检测的思路完全不同。它只拿正常样本训练让模型学会“正常长什么样”然后在推理阶段任何偏离正常分布的东西都会被标记为异常。这种范式的优势非常明显不需要收集缺陷样本不需要标注缺陷类型对未知缺陷有天然的泛化能力。当然代价也有——它只能告诉你“这个有问题”但没法告诉你“这是什么缺陷”而且阈值调校需要一些经验。MVTec AD数据集之所以成为这个领域的标杆就是因为它精准地模拟了真实工业场景15个类别涵盖纹理类如地毯、皮革、瓷砖和物体类如瓶子、螺丝、晶体管每个类别只提供正常样本用于训练测试集里包含各种缺陷类型。这个数据集的设计哲学就是你只能用正常数据学习然后去发现异常。1.2 主流无监督异常检测方案对比与选型目前工业界常用的无监督异常检测方案大致可以分成四类我整理了一个对比表格方便你根据自己场景做选择方案类型代表方法核心思想优势劣势适用场景重构类AutoEncoder, VAE学习正常样本的重构异常样本重构误差大原理直观实现简单有时能重构好异常区域导致漏检纹理类缺陷检测特征嵌入类PaDiM, PatchCore用预训练特征提取正常样本特征分布异常偏离分布无需训练效果好速度快依赖预训练模型质量物体类缺陷检测合成异常类DRAEM, CutPaste人工合成异常样本转化为监督问题可解释性强效果稳定合成策略影响大各类缺陷通用归一化流类FastFlow, CFlow学习正常样本的概率密度分布理论优雅异常评分连续训练相对复杂对异常评分要求高的场景如果你是第一次接触这个领域我强烈建议从PatchCore或者PaDiM入手。原因很简单这两个方法不需要训练直接用预训练模型提取特征就能跑出相当不错的效果能让你在半天之内就看到结果建立信心。等你对整个流程熟悉了再去尝试需要训练的FastFlow或者DRAEM。注意不要一上来就追求SOTA。工业质检的核心诉求是稳定、可复现、可解释而不是刷榜。一个能稳定跑出95% AUROC的简单方案远比一个时好时坏的复杂方案有价值。1.3 MVTec AD数据集的结构与使用要点MVTec AD的目录结构设计得很规范但第一次用的人容易在路径上踩坑。它的标准结构是这样的mvtec_anomaly_detection/ ├── bottle/ │ ├── train/ │ │ └── good/ # 只包含正常样本 │ ├── test/ │ │ ├── good/ # 测试集中的正常样本 │ │ ├── broken_large/ # 各类缺陷 │ │ ├── broken_small/ │ │ └── contamination/ │ └── ground_truth/ # 缺陷区域的像素级标注 │ ├── broken_large/ │ └── broken_small/ ├── cable/ ├── capsule/ └── ... (共15个类别)这里有几个关键点需要注意。第一训练集只有good文件夹这是无监督设定的核心你绝对不能把test里的缺陷样本混进训练。第二ground_truth文件夹里是二值mask图用于评估像素级定位精度如果你只做图像级分类可以不用但做分割就必须用。第三不同类别的图像尺寸不一样bottle是900x900tile是840x840transistor是840x840你在做数据加载时要统一resize到模型需要的尺寸。我个人的经验是先把bottle、tile、transistor这三个类别跑通。bottle代表物体类tile代表纹理类transistor代表复杂结构类这三个能覆盖大部分场景。跑通之后再扩展到全部15个类别。2. 从零搭建PatchCore异常检测模型2.1 环境准备与依赖安装工欲善其事必先利其器。这个项目的环境不算复杂但版本兼容性有几个坑我提前给你标出来。# 创建虚拟环境 conda create -n mvtec_ad python3.9 conda activate mvtec_ad # 核心依赖 pip install torch1.13.1 torchvision0.14.1 pip install timm0.6.12 # 预训练模型库 pip install opencv-python4.7.0.72 pip install scikit-learn1.2.2 # 用于coreset采样 pip install matplotlib3.7.1 pip install tqdm4.65.0 pip install pandas2.0.1为什么锁定这些版本torch 1.13.1和torchvision 0.14.1是经过大量项目验证的稳定组合timm 0.6.12对wide_resnet50_2的支持最完善。如果你用更新的版本可能会遇到timm加载预训练权重时的key不匹配问题。scikit-learn的KMeans和RandomProjection在1.2.2版本下表现稳定新版本有些API变动会导致coreset采样报错。提示如果你有GPU确保CUDA版本和torch匹配。用torch.cuda.is_available()验证返回True才算配置成功。CPU也能跑但15个类别全跑完可能要几个小时。2.2 PatchCore的核心原理拆解PatchCore的思路其实很朴素但效果出奇地好。我打个比方你就明白了假设你要判断一个人是不是这个公司的员工你不需要认识所有员工你只需要记住公司里所有员工的长相特征然后来了一个陌生人你发现他的特征和记忆里的所有员工都对不上那他就是异常。具体到算法层面PatchCore做了三件事第一步特征提取。用预训练的WideResNet50提取图像的中层特征。为什么用中层而不是最后一层因为最后一层的特征太抽象了丢失了空间位置信息而中层特征layer2和layer3既保留了语义信息又保留了足够的空间细节这对缺陷定位至关重要。第二步特征记忆库构建。把训练集所有正常图像的特征图切成一个个patch特征全部存进一个记忆库。假设你有200张训练图每张图提取出28x28784个patch特征每个特征维度是1536那记忆库就是200x784156800个特征向量。这个规模直接算最近邻太慢了。第三步Coreset采样。从156800个特征里贪心地选出最具有代表性的10%约15680个用这些代表点构成最终的记忆库。这样既大幅降低了计算量又保留了正常样本的分布信息。推理阶段对测试图的每个patch特征在记忆库里找最近邻计算距离。距离越大越异常。最后把patch级别的异常分数上采样回原图尺寸得到像素级的异常热力图。2.3 特征提取网络的选择与参数配置预训练模型的选择直接决定了特征质量。我对比过几种常见的backboneBackbone特征维度推理速度AUROC(bottle)推荐度ResNet18512最快97.2%入门可用WideResNet501536中等99.5%强烈推荐EfficientNet-B41792较慢99.1%可选ViT-Base768慢98.8%不推荐WideResNet50在精度和速度之间取得了最好的平衡。它的layer2输出特征图尺寸是原图的1/8layer3是1/16。PatchCore把这两层特征concat起来然后上采样到同一尺寸得到融合特征。import torch import torch.nn as nn import torchvision.models as models class FeatureExtractor(nn.Module): def __init__(self): super().__init__() # 加载预训练WideResNet50 backbone models.wide_resnet50_2(pretrainedTrue) # 取到layer2和layer3 self.layer1 nn.Sequential(*list(backbone.children())[:5]) self.layer2 nn.Sequential(*list(backbone.children())[5:6]) self.layer3 nn.Sequential(*list(backbone.children())[6:7]) def forward(self, x): # x: [B, 3, H, W] f1 self.layer1(x) # [B, 256, H/4, W/4] f2 self.layer2(f1) # [B, 512, H/8, W/8] f3 self.layer3(f2) # [B, 1024, H/16, W/16] # 把f2和f3上采样到同一尺寸后concat f2_up nn.functional.interpolate( f2, sizef3.shape[2:], modebilinear, align_cornersFalse) features torch.cat([f2_up, f3], dim1) # [B, 1536, H/16, W/16] return features这里有个细节特征图尺寸取决于输入图像尺寸。如果你输入224x224那layer3输出是14x14如果输入256x256输出是16x16。MVTec AD的原始图像比较大我建议统一resize到256x256这样每个patch的感受野大约是16x16像素对大多数缺陷尺寸都合适。3. 完整训练与推理流程实操3.1 数据加载与预处理管道数据加载看起来简单但有几个坑我踩过。MVTec AD的图像格式不统一有的是PNG有的是JPG有的带alpha通道有的不带。你需要一个健壮的加载器。import os import cv2 import numpy as np from torch.utils.data import Dataset, DataLoader from torchvision import transforms class MVTecDataset(Dataset): def __init__(self, root_dir, category, splittrain, img_size256): self.root os.path.join(root_dir, category) self.split split self.img_size img_size # 构建图像路径列表 self.img_paths [] self.labels [] self.mask_paths [] if split train: # 训练集只有good good_dir os.path.join(self.root, train, good) for fname in sorted(os.listdir(good_dir)): self.img_paths.append(os.path.join(good_dir, fname)) self.labels.append(0) self.mask_paths.append(None) else: # 测试集包含good和各类缺陷 test_dir os.path.join(self.root, test) for defect_type in sorted(os.listdir(test_dir)): defect_dir os.path.join(test_dir, defect_type) for fname in sorted(os.listdir(defect_dir)): self.img_paths.append(os.path.join(defect_dir, fname)) # good为0其他为1 self.labels.append(0 if defect_type good else 1) # 对应的ground truth mask if defect_type ! good: mask_name fname.split(.)[0] _mask.png mask_path os.path.join( self.root, ground_truth, defect_type, mask_name) self.mask_paths.append(mask_path) else: self.mask_paths.append(None) # 预处理resize 归一化 self.transform transforms.Compose([ transforms.ToPILImage(), transforms.Resize((img_size, img_size)), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.img_paths) def __getitem__(self, idx): # 用cv2读取处理各种格式 img cv2.imread(self.img_paths[idx]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 处理可能的alpha通道 if img.shape[2] 4: img img[:, :, :3] img_tensor self.transform(img) # 加载mask if self.mask_paths[idx] is not None: mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) mask cv2.resize(mask, (self.img_size, self.img_size)) mask (mask 127).astype(np.float32) else: mask np.zeros((self.img_size, self.img_size), dtypenp.float32) return { image: img_tensor, label: self.labels[idx], mask: torch.from_numpy(mask), path: self.img_paths[idx] }注意归一化参数用的是ImageNet的均值和标准差因为我们的backbone是在ImageNet上预训练的。如果你换了backbone归一化参数要跟着换。3.2 记忆库构建与Coreset采样实现这是PatchCore最核心的部分。记忆库的质量直接决定了检测效果。from sklearn.random_projection import SparseRandomProjection import torch.nn.functional as F class PatchCoreMemoryBank: def __init__(self, feature_dim1536, coreset_ratio0.1, projection_dim128, devicecuda): self.feature_dim feature_dim self.coreset_ratio coreset_ratio self.projection_dim projection_dim self.device device self.memory_bank None self.projection None def build(self, train_loader, model): 从训练集构建记忆库 model.eval() all_features [] with torch.no_grad(): for batch in train_loader: imgs batch[image].to(self.device) # 提取特征 [B, 1536, H, W] features model(imgs) B, C, H, W features.shape # 展平成patch特征 [B*H*W, C] features features.permute(0, 2, 3, 1).reshape(-1, C) all_features.append(features.cpu()) all_features torch.cat(all_features, dim0) print(f原始特征数量: {all_features.shape[0]}) # 随机投影降维加速后续最近邻搜索 self.projection SparseRandomProjection( n_componentsself.projection_dim, random_state42) projected self.projection.fit_transform(all_features.numpy()) projected torch.from_numpy(projected).float() # Coreset贪心采样 n_coreset int(projected.shape[0] * self.coreset_ratio) indices self._greedy_coreset(projected, n_coreset) self.memory_bank all_features[indices].to(self.device) print(fCoreset后特征数量: {self.memory_bank.shape[0]}) return self def _greedy_coreset(self, features, n_samples): 贪心coreset采样每次选离已选集合最远的点 n_total features.shape[0] selected [np.random.randint(n_total)] min_dists torch.full((n_total,), float(inf)) for i in range(n_samples - 1): # 计算所有点到最新选中点的距离 last_selected features[selected[-1]] dists torch.norm(features - last_selected, dim1) # 更新最小距离 min_dists torch.min(min_dists, dists) # 选最小距离最大的点 next_idx torch.argmax(min_dists).item() selected.append(next_idx) if (i 1) % 1000 0: print(f 已采样 {i1}/{n_samples}) return selectedCoreset采样的比例是个超参数。我试过5%、10%、20%三档10%是性价比最高的。5%在复杂类别如transistor上会掉点20%提升有限但推理变慢。贪心采样在15万特征上跑完大概需要几分钟如果你嫌慢可以用随机采样代替效果差1-2个点。3.3 推理与异常评分计算推理阶段要做的事情对测试图的每个patch特征在记忆库里找最近邻距离作为异常分数。class PatchCoreDetector: def __init__(self, memory_bank, projection, img_size256, devicecuda): self.memory_bank memory_bank self.projection projection self.img_size img_size self.device device # 特征图尺寸 img_size / 16 self.feat_size img_size // 16 def predict(self, image, model): 对单张图像进行异常检测 model.eval() with torch.no_grad(): # 提取特征 [1, 1536, H, W] features model(image.unsqueeze(0).to(self.device)) B, C, H, W features.shape # 展平 [H*W, C] patch_features features.permute(0, 2, 3, 1).reshape(-1, C) # 投影降维 projected self.projection.transform( patch_features.cpu().numpy()) projected torch.from_numpy(projected).float().to(self.device) # 计算到记忆库的最近邻距离 # 分块计算避免显存溢出 dists self._compute_nn_distance(projected) # 重塑为特征图尺寸 anomaly_map dists.reshape(H, W).cpu().numpy() # 上采样到原图尺寸 anomaly_map cv2.resize( anomaly_map, (self.img_size, self.img_size), interpolationcv2.INTER_LINEAR) # 图像级异常分数 最大值 image_score anomaly_map.max() return { anomaly_map: anomaly_map, image_score: image_score } def _compute_nn_distance(self, query_features, chunk_size1024): 分块计算最近邻距离避免显存溢出 n_query query_features.shape[0] all_dists [] for i in range(0, n_query, chunk_size): chunk query_features[i:ichunk_size] # [chunk, 1, C] - [1, M, C] - [chunk, M] dists torch.cdist(chunk, self.memory_bank) min_dists dists.min(dim1)[0] all_dists.append(min_dists) return torch.cat(all_dists, dim0)这里有个工程上的细节torch.cdist在记忆库很大时会爆显存。假设query有784个patch记忆库有15680个特征那距离矩阵是784x15680float32下约47MB还好。但如果你的记忆库有10万特征那就得用分块计算。我上面的代码已经做了分块处理。3.4 评估指标计算与阈值选择工业质检最关心的指标是AUROC和F1。AUROC衡量的是排序质量F1需要你选一个阈值。from sklearn.metrics import roc_auc_score, precision_recall_curve, f1_score def evaluate(detector, test_loader, model): all_scores [] all_labels [] all_maps [] all_masks [] for batch in test_loader: imgs batch[image] labels batch[label] masks batch[mask] for i in range(imgs.shape[0]): result detector.predict(imgs[i], model) all_scores.append(result[image_score]) all_labels.append(labels[i].item()) all_maps.append(result[anomaly_map]) all_masks.append(masks[i].numpy()) all_scores np.array(all_scores) all_labels np.array(all_labels) # 图像级AUROC img_auroc roc_auc_score(all_labels, all_scores) # 像素级AUROC flat_maps np.concatenate([m.flatten() for m in all_maps]) flat_masks np.concatenate([m.flatten() for m in all_masks]) pix_auroc roc_auc_score(flat_masks, flat_maps) # 找最佳F1阈值 precision, recall, thresholds precision_recall_curve( all_labels, all_scores) f1_scores 2 * precision * recall / (precision recall 1e-8) best_idx np.argmax(f1_scores) best_threshold thresholds[best_idx] if best_idx len(thresholds) else 0.5 best_f1 f1_scores[best_idx] print(f图像级 AUROC: {img_auroc:.4f}) print(f像素级 AUROC: {pix_auroc:.4f}) print(f最佳 F1: {best_f1:.4f} (阈值{best_threshold:.4f})) return { img_auroc: img_auroc, pix_auroc: pix_auroc, best_f1: best_f1, threshold: best_threshold }阈值选择有个经验不要用测试集来选阈值。正确做法是从训练集中切出一部分正常样本作为验证集用验证集上的分数分布来确定阈值。通常取正常样本分数的99%分位数作为阈值这样误报率控制在1%左右。4. 实操中的典型问题与排查技巧4.1 常见报错与解决方案速查表问题现象可能原因排查方法解决方案CUDA out of memory记忆库太大或batch过大打印记忆库shape降低coreset_ratio或分块计算AUROC只有50%左右特征提取有问题可视化特征图检查backbone是否加载了预训练权重所有图像分数都很高归一化参数不对检查输入像素范围确认mean/std与预训练模型匹配缺陷定位不准特征图分辨率太低打印特征图尺寸增大输入尺寸或融合更浅层特征推理速度太慢记忆库太大计时最近邻搜索增大投影降维力度或减少coreset某些类别效果差该类别纹理特殊单独评估各类别调整输入尺寸或换backbone4.2 提升检测精度的五个实战技巧技巧一多尺度特征融合。只用layer3的特征会丢失小缺陷。我把layer2和layer3融合后在transistor类别上像素级AUROC从94.2%提升到了97.1%。融合方式就是上采样到同一尺寸后concat。技巧二输入尺寸不要太小。224x224虽然快但小缺陷在特征图上可能只占一个像素。我实测256x256比224x224在bottle类别上图像级AUROC高0.8个点320x320又能再高0.3个点但推理时间翻倍。256是甜点。技巧三coreset采样用贪心而非随机。随机采样在纹理类如carpet上会掉2-3个点因为纹理类的正常样本分布比较分散随机采样容易漏掉某些模式。贪心采样虽然慢但稳定。技巧四后处理用高斯滤波。异常热力图往往有噪声用5x5的高斯核滤波一下像素级AUROC能提升0.5-1个点。这个操作在OpenCV里一行代码cv2.GaussianBlur(anomaly_map, (5,5), 1.0)。技巧五阈值按类别自适应。不同类别的正常样本分数分布不一样用一个全局阈值会导致某些类别误报率高。我的做法是每个类别单独算阈值取该类正常验证样本分数的99.5%分位数。4.3 从实验到产线部署的注意事项实验室里跑出高AUROC只是第一步真正上产线还有几个坑要填。第一推理延迟。产线节拍可能是几百毫秒一件你的模型必须在这个时间内完成推理。PatchCore在256x256输入下用V100大概30ms一张用CPU大概300ms。如果节拍要求高要么上GPU要么用更小的backbone。第二光照变化。实验室打光稳定产线上光照可能波动。我建议在训练数据里加入一些光照增强的样本或者用直方图均衡化做预处理。MVTec AD的数据光照很稳定直接迁移到产线可能会掉点。第三误报率控制。产线上误报意味着停线检查成本很高。宁可漏检也不能误报太多。我的经验是把阈值设高一点让误报率控制在0.5%以下漏检率可以通过多模型投票来降低。第四模型更新。产线换批次、换材料后正常样本的分布会漂移。你需要一个机制定期用新的正常样本更新记忆库。最简单的做法是每周用新数据重新构建一次记忆库不需要重新训练backbone。提示部署时把backbone和记忆库分开。backbone可以用TensorRT加速记忆库用FAISS做最近邻搜索这样推理速度能再提升3-5倍。4.4 扩展方向与进阶思路如果你已经把PatchCore跑通了想进一步提升有几个方向可以尝试。方向一换更强的backbone。用DINOv2或者CLIP的视觉编码器提取特征在MVTec AD上能比WideResNet50高1-2个点。但推理速度会慢不少需要权衡。方向二引入合成异常。DRAEM的思路是在正常图上贴一些人工合成的缺陷然后训练一个分割网络。这种方法在缺陷类型已知的场景下效果很好而且推理速度比PatchCore快。方向三多类别统一模型。PatchCore每个类别要单独建记忆库15个类别就是15个库。你可以尝试用一个模型同时处理所有类别用类别标签作为条件。这个方向目前还在研究中工业界落地案例不多。方向四结合传统视觉。对于划痕、脏污这类缺陷传统视觉方法如Gabor滤波、局部二值模式有时候比深度学习更稳定。我通常的做法是深度学习做粗筛传统视觉做精判两者互补。我个人在实际操作中的体会是工业质检没有银弹。PatchCore是一个很好的起点但它不是终点。真正要做好一个产线项目你需要深入理解你的缺陷类型、产线节拍、误报成本然后针对性地调整方案。MVTec AD只是一个练兵场真正的战场在产线上。多跑几个类别多调几次参数多看看失败案例比读十篇论文都有用。