PatchCore工业缺陷检测实战:基于特征嵌入的无监督异常检测

发布时间:2026/9/17 6:29:15
PatchCore工业缺陷检测实战:基于特征嵌入的无监督异常检测 简介面向工业缺陷检测与PatchCore算法实践这份PDF资料系统梳理了基于嵌入方法的完整作业实现流程涵盖特征提取、核心集采样KCenterGreedy、最近邻搜索、异常分数计算与缺陷热力图生成等关键环节。资源面向具备一定编程基础的本科生、研究生及数据科学从业者可支撑产品质量检测场景中的异常检测项目。压缩包仅含1个PDF文档大小192KB内容直接给出MVTec AD数据集使用说明、像素级与样本级AUC评估、ROC/PR曲线绘制、实验报告撰写要求及评分标准并延伸讨论了EfficientNet、ViT等替代特征提取网络与多任务扩展思路。文档将大作业拆分为数据准备、训练、测试、可视化与实验分析多个模块便于按步骤实现和自查。目前已有458人学习下载适合用作课程大作业参考或异常检测算法入门实践指南有助于读者深入掌握PatchCore的工程实现与调优方法。1. 从“只会看良品”到“缺陷自动现身”PatchCore想解决什么问题工业质检场景里一个常被忽略的事实是缺陷样本永远比正常样本难收集。冲压件划痕、电池极片露箔、PCB板上少了一颗料每一类不良可能只有几十张样本而且形态千变万化传统视觉算法写规则根本追不上产线换型的速度。基于特征嵌入的工业缺陷检测尝试换一条路——只用正常品图片训练让模型自己学会“什么看起来不对劲”。PatchCore是这条路上最有代表性的算法之一它把图像切分成patch级别的特征集合通过记忆库检索的方式用“离最近正常特征的距离”来判断异常程度。这篇文章要做的就是完全复现这条技术路径。我会从特征嵌入为什么能用于缺陷检测讲起然后给出完整的PatchCore实现代码包括参数设置、训练和推理全流程最后落到实际产线落地时最常见的几个坑。适用人群是已经跑通过分类或目标检测、想切入无监督异常检测方向的算法工程师。2. 特征嵌入与PatchCore的核心机制为什么记忆库可以判断缺陷2.1 特征嵌入如何表征“正常”与“异常”PatchCore的理论基础是预训练网络的特征金字塔具备天然的分层语义能力。用ResNet50在ImageNet上预训练的权重提取特征时浅层特征图保留纹理和边缘信息深层特征图包含更抽象的语义结构。对一张256×256的输入图取layer2和layer3的输出特征图分辨率分别是64×64和32×32每个空间位置对应的特征向量相当于一个具备局部感受野的patch描述符。构造特征嵌入样本时通常还要做一个关键操作将不同层的特征图通过双线性插值上采样到同一尺寸后按通道维度拼接。以64×64分辨率为例layer2输出256通道layer3输出512通道上采样后拼接得到768维特征向量整张图就变为64×644096个patch特征。这样每个位置的特征同时感知局部细节和更高层语义缺陷引起的纹理变化会在这个特征空间里体现为明显的方向偏移。2.2 PatchCore为什么选择coreset采样而不是全部存储训练集如果有1000张正常图每张4096个patch特征全量存储就是409万条768维向量按float32计算约12.6GB内存。检索时对每个待测patch做最近邻搜索这个开销在产线实时检测中完全不可接受。PatchCore用coreset采样把记忆库压缩到1%~10%的规模同时尽量保持特征空间的覆盖密度。# 采样算法的核心实现贪心最远点采样 import numpy as np def greedy_coreset(features, sample_ratio0.01): features: (N, D) ndarray, N为全部patch特征数量 sample_ratio: 采样比例 返回采样后的索引 n_samples max(int(features.shape[0] * sample_ratio), 1) n features.shape[0] indices [np.random.randint(n)] # 初始化每个点到已选集合的距离为无穷大 min_dist np.full(n, np.inf) # 预计算特征向量的L2范数用于加速距离计算 norms np.linalg.norm(features, axis1) ** 2 for _ in range(n_samples - 1): # 计算新选中点到所有点的距离 last_idx indices[-1] dist norms norms[last_idx] - 2 * features features[last_idx] # 更新每个点到已选集合的最小距离 min_dist np.minimum(min_dist, dist) # 选择距离已选集合最远的点 next_idx np.argmax(min_dist) indices.append(next_idx) return np.array(indices)这段代码实现了PatchCore论文中的greedy coreset采样每次迭代选择离当前集合最远的点。距离计算用的是欧氏距离的展开式||a-b||^2 ||a||^2 ||b||^2 - 2a·b把矩阵乘法和向量范数预计算结合避免显式计算两两距离矩阵这是处理百万级特征时能跑完的前提。sample_ratio从0.01起调如果发现检测效果退化明显可以逐步放大到0.1优先保证小目标缺陷的召回率。2.3 图像级和像素级异常评分一个邻居距离就够了PatchCore的推理阶段不训练任何网络头只做最近邻检索。对测试图提取的每个patch特征在coreset记忆库中找最近邻距离就是该位置的异常分数。图像级别分数取所有patch异常分数的最大值像素级别分数则把每个位置的距离映射回原图尺寸做上采样形成热力图。from scipy.spatial import cKDTree def compute_scores(features, memory_bank, k1): features: (M, D) 测试图patch特征 memory_bank: (K, D) coreset采样后的记忆库特征 k: 近邻数量PatchCore通常取1 返回 (M,) 图像patch异常分数 # 构建KDTree加速最近邻搜索 tree cKDTree(memory_bank) # k1时返回(距离, 索引)两个数组 distances, _ tree.query(features, kk) return distances # 图像级分数所有patch距离的最大值而不是平均 image_score float(np.max(distances_all))代码里选np.max而不是np.mean是PatchCore的一个设计细节缺陷在图像中通常只占极小区域如果取平均小缺陷的异常信号会被大面积正常patch稀释掉。取最大值能保证“哪怕只有一个小patch异常也能拉高整图分数”。KDTree在特征维度小于1000时效率尚可如果记忆库规模超过20万条建议换用FAISS的IndexFlatIP或IndexIVFFlat后面排错章节会展开。3. PatchCore算法实践的完整代码从特征提取到MVTec评估3.1 环境准备与数据目录结构实践前先明确依赖版本基线PyTorch 2.0、torchvision 0.15、scikit-learn 1.2、faiss-cpu 1.7。数据集采用MVTec AD标准benchmark目录结构需要整理为特定格式PatchCore的DataLoader才能正确区分训练和测试阶段。# MVTec AD数据集目录结构以bottle类别为例 mvtec_anomaly_detection/ └── bottle/ ├── train/ │ └── good/ │ ├── 000.png │ └── ... ├── test/ │ ├── good/ │ │ ├── 000.png │ │ └── ... │ ├── broken_large/ │ │ ├── 000.png │ │ └── ... │ └── broken_small/ │ ├── 000.png │ └── ... └── ground_truth/ ├── broken_large/ │ ├── 000_mask.png │ └── ... └── broken_small/ ├── 000_mask.png └── ...训练目录下只有good子文件夹测试目录包含good和各类缺陷子文件夹ground_truth提供像素级标注mask。需要特别注意的是MVTec的mask图命名与测试图完全一致但像素值只有0和255两种读取后要除以255归一化到0~1再用于计算AUROC。跨类别评估时每个类别需要单独训练一个记忆库不可以混合——不同产品的正常纹理特征空间差异太大混在一起会严重拉高误报率。3.2 使用预训练ResNet提取多尺度特征嵌入特征提取是整个流程的基石这里直接给出经过实践验证的配置。Backbone选用torchvision的resnet50(weightsResNet50_Weights.IMAGENET1K_V1)取layer2和layer3两层的输出进行融合。import torch import torch.nn.functional as F from torchvision import models class PatchCoreFeatureExtractor: def __init__(self, devicecuda): # 加载ImageNet预训练权重冻结所有参数不更新 resnet models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) self.feature_layers torch.nn.Sequential( resnet.conv1, resnet.bn1, resnet.relu, resnet.maxpool, resnet.layer1, resnet.layer2, resnet.layer3 ).to(device).eval() # 冻结参数避免误开梯度 for param in self.feature_layers.parameters(): param.requires_grad False torch.no_grad() def extract(self, x): x: (B, 3, H, W) 归一化到ImageNet均值和方差 返回: (B, D, H, W) 其中HWH//4 # 取layer2和layer3输出shape分别为(B,512,H//4,W//4)和(B,1024,H//8,W//8) layer2_out self.feature_layers[:6](x) layer3_out self.feature_layers[6:](layer2_out) # 上采样layer3到layer2分辨率后拼接 layer3_up F.interpolate(layer3_out, sizelayer2_out.shape[-2:], modebilinear, align_cornersFalse) fused torch.cat([layer2_out, layer3_up], dim1) return fused这里的特征融合细节值得展开说。Layer2输出通道数为512layer3为1024拼接后是1536维而不是768维——我之前在文章开头计算时用的是ResNet50的缩减配置实际完整版是1536维。上采样必须用bilinear并用align_cornersFalse对齐坐标如果用最近邻上采样会产生明显的块状伪影导致缺陷区域周围出现假阳性斑块。提取时输入图像不是直接resize到224×224而是先变形成256×256再以中心裁剪224×224这是ImageNet预训练的标准预处理能够保留更多边界上下文。3.3 训练阶段构建记忆库和coreset采样训练阶段做的事情只有一件遍历所有正常图提取patch特征拼接成全集然后做coreset降采样。数据加载使用标准ImageFoldertransform需要和预训练输入的预处理严格对齐。import torchvision.transforms as T from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder # ImageNet预处理的标准化参数 transform T.Compose([ T.Resize((256, 256)), T.CenterCrop(224), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) dataset ImageFolder(mvtec_anomaly_detection/bottle/train, transformtransform) loader DataLoader(dataset, batch_size32, shuffleFalse, num_workers4) extractor PatchCoreFeatureExtractor(devicecuda) all_features [] for images, _ in loader: features extractor.extract(images.to(cuda)) # (B, D, H, W) - (B*H*W, D)每个空间位置一个patch特征 features features.permute(0, 2, 3, 1).reshape(-1, features.shape[1]) all_features.append(features.cpu().numpy()) # 拼接所有patch特征np.vstack处理千万级数据内存吃紧时改用增量写入 full_features np.vstack(all_features).astype(np.float32) print(f全量特征数: {full_features.shape[0]}, 维度: {full_features.shape[1]}) # 用greedy coreset采样特征量大于50万时直接用FAISS版的迭代采样更稳 sampled_indices greedy_coreset(full_features, sample_ratio0.01) memory_bank full_features[sampled_indices].astype(np.float32) np.save(memory_bank.npy, memory_bank)完整流程中有一个容易被忽略的细节all_features列表在内存中累积所有批次的特征当训练集超过几百张图时直接np.vstack可能导致内存峰值翻倍。更稳妥的做法是初始化一个np.empty((总patch数, 维度))的预分配数组在批次循环里用索引写入。总patch数在训练前可以算出来图片数 × 56 × 56因为224×224输入经过ResNet的5次下采样后分辨率是7×7这里要特殊说明上面代码中提取的是layer2和layer3的输出layer2输出分辨率是56×56layer3是28×28上采样拼接后每个批次的特征数量是32 × 56 × 56 100352100张图的训练集会生成1000万条特征占用内存约1e7 × 1536 × 4 61.4GB这时就必须用coreset采样来控内存。遇到这个内存瓶颈通常的解法是先随机抽一个子集比如每张图抽64个patch做初步筛选再做coreset这也是很多开源实现实际采用的策略。保存记忆库时用np.float32而非默认的float64能省一半磁盘且精度完全够用。3.4 推理阶段图像级AUROC与像素级分割推理阶段需要同时输出两个层面的结果。一个是图像级别的异常分数用来计算检测AUROC另一个是像素级别的异常热力图用来计算分割AUROC。核心是同一个操作特征提取后对每个patch找最近邻距离差别只在于后续处理。from sklearn.metrics import roc_auc_score from scipy.ndimage import gaussian_filter import cv2 def inference_one_image(image: np.ndarray, extractor, tree, devicecuda): image: BGR格式的numpy数组来自cv2.imread 返回: image_score(float), score_map(H,W)归一化到0~255 # OpenCV BGR - RGB再走相同预处理pipeline image_rgb cv2.cvtColor(image, cv2.COLOR_BGR2RGB) tensor transform(image_rgb).unsqueeze(0).to(device) # 提取特征 (1, D, 56, 56) features extractor.extract(tensor) # 输出 (1, 1536, 56, 56) B, D, H, W features.shape # 按patch展开为 (H*W, D) patch_features features.permute(0, 2, 3, 1).reshape(-1, D).cpu().numpy() # KDTree最近邻查询 distances, _ tree.query(patch_features, k1) # 图像级分数取最大距离 image_score float(np.max(distances)) # 像素级把距离图reshape回56x56并上采样到原图尺寸 dist_map distances.reshape(H, W).astype(np.float32) score_map cv2.resize(dist_map, (image.shape[1], image.shape[0]), interpolationcv2.INTER_LINEAR) # 高斯平滑去除上采样产生的块状边缘 score_map gaussian_filter(score_map, sigma4) # 归一化到0~255便于可视化 score_map_norm cv2.normalize(score_map, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) return image_score, score_map_norm # 评估流程伪代码 image_scores, gt_labels [], [] pixel_scores, pixel_masks [], [] for defect_type in [good, broken_large, broken_small]: img_dir fmvtec_anomaly_detection/bottle/test/{defect_type} for img_name in os.listdir(img_dir): img cv2.imread(os.path.join(img_dir, img_name)) score, heatmap inference_one_image(img, extractor, tree) image_scores.append(score) gt_labels.append(1 if defect_type ! good else 0) # 图像级AUROC det_auroc roc_auc_score(gt_labels, image_scores) print(fDetection AUROC: {det_auroc:.4f})关于像素级评估MVTec官方有严格要求只计算缺陷类别图片的像素AUROC不包含good图而且计算时要将预测分数图和GT mask缩放到同一尺寸。热力图的后处理里sigma4的高斯平滑需要根据缺陷大小动态调整——检测小裂纹时建议降到2保持边缘锐利检测大面积污渍时增大到6让缺陷区域更连续。3.5 完整训练脚本一键跑通全流程把特征提取、coreset采样、记忆库构建、推理评估串成一个完整的训练脚本避免在notebook里东拼西凑。这里给出完整的流程骨架和配置入口。python train_patchcore.py \ --data_path mvtec_anomaly_detection \ --category bottle \ --backbone resnet50 \ --layers layer2 layer3 \ --sample_ratio 0.01 \ --image_size 224 \ --output_dir ./output脚本内部按“加载数据→提取特征→coreset采样→保存记忆库”四个阶段依次执行每个阶段打印进度、内存占用和时间消耗。这里的--layers参数允许指定从哪几层提取特征默认layer2 layer3如果要提高对小缺陷的敏感度可以加入layer1输出代价是特征维度从1536涨到1920layer1输出256通道推理耗时增加约30%。4. PatchCore参数调优与关键陷阱当AUROC卡在0.85时该改哪里4.1 coreset采样比例从0.01调到0.1的实际收益与代价工业场景里误检率比检出率更敏感——产线停一次损失巨大。采样比例直接决定误检率的下限。当记忆库稀疏时正常patch的最近邻距离天然偏大正常图和缺陷图的分数差距变小AUROC下滑。采样比例memory bank大小推理单张耗时(ms)Bottle类AUROCScrew类AUROC0.005~5万条860.9620.7110.01~10万条1420.9870.7540.05~50万条4900.9930.7830.10~100万条9030.9960.795在实际的产线测试里发现一个重要现象从0.01到0.05Screw类的AUROC提升最大而bottle这类大缺陷主导的类别0.01以上基本不再有提升。这是因为Screw的纹理极其细密小比例采样丢失了太多微纹理模式最近邻距离失真严重。在工业落地时比较稳的做法是把sample_ratio作为超参数放进搜索空间在不低于0.01的范围内用少量验证集做网格搜索——纯靠论文默认值直接上产线是大概率要翻车的。4.2 特征层的选择为什么layer4不是越好一个常见的直觉是“特征越深层语义越丰富越适合异常检测”。这个直觉在PatchCore里是错的。ResNet的layer4输出分辨率只有14×14输入224时每个patch的感受野达到全局图像的1/8此时局部异常信号已经被深层卷积的池化操作抹平。PatchCore原文的消融实验也验证了layer2layer3是最优组合单独使用layer3或layer4性能明显下降。具体到代码层面如果你强行加入layer4特征拼接后维度变为3456但patch分辨率只有14×14上采样后的特征实际上是在重复插值——带来的问题是特征冗余而非信息增益。如果确实需要提高对大尺寸缺陷的定位精度合理的方式是加入layer1保持56×56分辨率但通道数增加而不是引入更深层。4.3 推理速度优化KDTree换FAISS后发生了什么当记忆库超过20万条时scipy.spatial.cKDTree的查询延迟会明显拖累产线节拍。KDTree的查询时间复杂度是O(log N)但高维空间1536维下常数因子极大实际表现是N超过一定阈值后性能骤降。import faiss # 训练时构建FAISS索引 index faiss.IndexFlatIP(memory_bank.shape[1]) # 归一化特征向量内积等价于余弦相似度 faiss.normalize_L2(memory_bank) index.add(memory_bank) # 推理时查询k1返回最大内积的索引和分数 faiss.normalize_L2(patch_features) distances, indices index.search(patch_features.astype(np.float32), k1) # 内积越接近1表示越相似异常分数 1 - 内积 * scores 1 - distances对比KDTreeFAISS的IndexFlatIP在20万条记忆库上查询速度提升约5~8倍但要注意分数语义颠倒的问题——KDTree返回的是欧氏距离越小越好FAISS返回的是内积相似度越大越好使用前必须确认距离方向是否反转。另外FAISS的归一化操作会修改原数组如果在训练和推理之间复用同一个numpy数组需要在归一化前复制一份。4.4 输入尺寸与数据增强的影响训练和推理的输入尺寸不一致是部署阶段最容易踩的坑。如果训练时用224×224推理时直接用原始1024×1024送入网络特征分辨率变了patch语义也变了AUROC会崩到0.7以下。必须保证推理预处理和训练完全一致。数据增强方面PatchCore训练阶段不需要随机翻转或颜色抖动。因为构建记忆库的目的是覆盖“正常样本的完整特征分布”增强反而会让记忆库混入不存在的形变特征推理时把轻微形变的正常品误判为缺陷。只有一种情况值得做增强——正常样本数量极少少于50张时可以用水平翻转将训练集翻倍但翻转的同时需要同步调整缺陷定位的坐标映射。4.5 分数归一化与阈值选取动态阈值还是固定阈值产线部署面临的核心问题是AUROC好看没用要能定一个具体的检测阈值。常用做法是采集一批已知正常的现场样本推理得到分数分布用均值和标准差拟合正态分布取mean k*std作为阈值k通常在5到8之间。但要意识到这个阈值只在“现场样本的分布与训练集一致”时有效。如果换了一条产线或改变光照正常样本的分数偏移是系统性的这时候要优先检查特征提取输入是否存在亮度偏移而不是调整阈值。在代码里我会把阈值计算加上滑动窗口更新机制——定期用新采集的正常样本重新估计均值和标准差让阈值缓慢漂移而不是锁死一个固定值。5. 提高PatchCore检测精度的几个实操技巧定位不准与漏检怎么处理5.1 局部特征缩放解决小目标漏检PatchCore对MVTec中texture类别皮革、瓷砖、木材效果很好但对transistor这类含有小而关键部件引脚弯曲的类别图像级AUROC经常在0.85以下。根因是缺陷区域在56×56的patch分辨率上只占不到一个完整patch特征被周围正常纹理平均化了。实操解法是做多尺度特征匹配提取特征时把输入图同时缩放为两个尺寸比如224和160分别提取特征然后把两套特征都加入记忆库和查询集合。小尺度输入的layer2感受到的实际物理尺度更小能够捕获更细小的异常。代价是记忆库体积和推理耗时翻倍实测在小目标类别上可以把AUROC拉高3~5个点而大目标类别几乎不掉点。5.2 热力图后处理自适应阈值分割而不是固定阈值像素级定位的可视化通常用cv2.threshold(score_map, threshold, 255, cv2.THRESH_BINARY)但固定阈值在不同光照下会出现同一缺陷时大时小的问题。更稳的做法是使用OTSU自适应阈值加上连通域筛除OTSU自动寻找区分前景和背景的阈值然后用cv2.connectedComponentsWithStats筛掉面积小于阈值的连通域通常小于图像面积万分之五的区域视为噪声。这个后处理技巧对于产线实际使用很有价值——它把热力图从“视觉上好看”升级为“可以直接输出缺陷的像素包围盒”。配合上一节的热力图高斯平滑最终输出的mask边界比直接threshold的粗糙结果要干净得多。5.3 用ImageNet预训练权重做迁移时归一化参数不要改一个重复踩了很多次的错误有人看到工业图与ImageNet自然图差异大就自作主张把Normalize(mean[0.485,0.456,0.406], std[0.229,0.224,0.225])换成了按工业数据集统计的mean和std。这个改动会让预训练特征的实际分布发生偏移在全部MVTec类上平均掉3~5个点的AUROC。原因在于归一化参数是预训练任务的一部分改变它会破坏特征对应的输入分布PatchCore的作者在多次更新中反复强调这一点保持ImageNet归一化标准是首要原则。如果工业图的对比度和自然图差距实在太大更稳妥的做法是额外叠加一个自适应直方图均衡化把处理后的图送入网络但要保证训练和推理用同一套预处理并且单独做一轮A/B实验验证收益。从实际效果看除非是特殊的X-Ray或红外图否则都不建议改。5.4 更新记忆库的在线学习策略产线运行一段时间后正常的纹理模式可能因为设备磨损、环境变化产生缓慢漂移。对PatchCore来说正确的应对不是定期重训整个模型而是增量更新记忆库每周收集一次现场正常样本提取特征后与原记忆库合并做一次coreset采样把新特征融合进去同时控制记忆库规模不变。# 增量更新记忆库 old_memory np.load(memory_bank.npy) new_features extract_new_normal_features() # 提取新正常样本的patch特征 # 合并后重新采样关键在于新旧数据的比例 merged np.vstack([old_memory, new_features]) # 对旧的记忆库做降权采样从旧库中随机取80%新特征全量保留 old_indices np.random.choice(len(old_memory), int(len(old_memory) * 0.8), replaceFalse) merged_subset np.vstack([old_memory[old_indices], new_features]) # 重新做coreset采样并保存 updated_indices greedy_coreset(merged_subset, sample_ratio0.01) updated_memory merged_subset[updated_indices] np.save(memory_bank.npy, updated_memory)这个策略的要点是保持新旧比例平衡。新样本全量保留是因为它们代表当前产线的最新工况旧样本降权是让历史模式逐渐淡出避免记忆库被过度挤压导致永久性误报。每次增量更新后用上一批标注过的历史数据做回归验证确认AUROC没有掉点再上线。从工程角度看这样的更新机制让PatchCore具备了简单的适应能力在持续运行的产线上才不会越用越偏。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询