ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PatchCore工业缺陷检测:特征嵌入与最近邻异常检测的工程实现

PatchCore工业缺陷检测:特征嵌入与最近邻异常检测的工程实现 简介一份聚焦工业品质检场景的算法实践资料面向机器学习、图像处理方向的本科生、研究生及数据科学从业者。内容基于特征嵌入的缺陷检测思路系统梳理了基于重构与基于嵌入两类主流方法重点讲解PatchCore的特征提取、KCenterGreedy核心集采样、最近邻搜索和异常热力图生成等关键环节并围绕MVTec AD与真实工业数据给出数据预处理、模型训练、缺陷检测、实验分析等可执行任务包含分块大小、重叠比例、采样比例等参数调优方向。资源为单个PDF文档压缩包仅192KB信息密度较高。目前已有459人学习浏览适合课程大作业或入门实践。读者可从中获取数据加载与预处理策略、像素级与样本级AUC评估方法、ROC与PR曲线绘制思路以及功能实现、实验报告、代码规范等评分细则有助于按步骤完成缺陷检测项目同时深入理解基于嵌入方法的核心思想。1. 工业缺陷检测里的 PatchCore一个用正常样本就够了的方法在 MVTec AD 上跑过基于重构的缺陷检测方案你大概率会遇到一个尴尬问题正常样本学得越“好”测试时缺陷反而越容易被重构出来漏检率直线上升。PatchCore 的思路完全不同——它不学“怎么把图像变回去”而是用正常样本的特征嵌入建一个“记忆库”测试时只比对当前样本和记忆库的相似度离得远就是缺陷。这个思路让 PatchCore 在准确率和推理效率上都明显优于重构类方法也是工业缺陷检测里“基于嵌入”这条技术路线的代表算法。本文从原理讲到工程实现再给到 MVTec AD 和真实数据集上都能落地的完整流程适合正在做缺陷检测作业、或者要在产线上快速验证异常检测方案的同学。2. 核心机制拆解特征嵌入、核心集采样与最近邻打分2.1 特征提取用预训练 ResNet50 中间层做 patch 描述子PatchCore 不用训练特征提取器直接用 ImageNet 预训练的 ResNet50 做 backbone取中间层的特征图。取哪一层有讲究太浅的层语义信息不足背景和缺陷区分不开太深的层空间分辨率太低小缺陷在特征图上可能只有一个像素。常见做法是取 ResNet50 的 layer2 和 layer3 输出对应网络的第 5、6 个残差块得到输入图像 1/16 分辨率的特征图。每个空间位置对应一个高维向量这个向量就是这个 patch 的嵌入描述。import torch import torch.nn as nn from torchvision import models def build_feature_extractor(device): # 使用 ImageNet 预训练 ResNet50去掉分类头 backbone models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) backbone.eval() # 取到 layer3 输出特征图尺寸为输入的 1/16 layers list(backbone.children())[:7] extractor nn.Sequential(*layers) return extractor.to(device) def extract_patch_features(model, batch): # 输入 batch: [B, 3, H, W]输出特征图 [B, C, H/16, W/16] with torch.no_grad(): feats model(batch) # 把空间位置展开成 patch 特征每行代表一个 patch 的嵌入 B, C, H, W feats.shape feats feats.permute(0, 2, 3, 1).reshape(B, H * W, C) return feats这里list(backbone.children())[:7]截取到 layer3 结束输出通道数是 1024每个特征向量维度 1024。把[B, C, H, W]排列成[B, H*W, C]之后HW 个位置就是 HW 个 patch 描述子后续的采样和检索都基于这个格式。注意提取特征时一定要开torch.no_grad()并保持模型在 eval 模式BatchNorm 的统计量在推理和训练时行为不同会影响特征稳定性。2.2 核心集采样KCenterGreedy 为什么能压住存储库规模直接从所有正常训练图中提取 patch 特征一个 256x256 的输入会产生 256 个 patch1/16 分辨率是 16x16256 个位置。几百张训练图就是几万个特征向量每张测试图都要和这几万个向量算距离检索开销完全不现实。PatchCore 用 KCenterGreedy 做核心集采样目标是用尽量少的特征向量“覆盖”整个正常样本的特征空间。核心思想是贪心选点先选离所有点均值最远的点之后每一步选一个离已选集合最远的点重复直到达到预算。这样选出来的核心集能保留特征空间的轮廓把存储库从几万压缩到几百精度损失却很小。def kcenter_greedy(features, sample_ratio0.01): # features: [N, D]N 为特征总数 n features.shape[0] m max(int(n * sample_ratio), 1) # 一次性计算全部距离矩阵N 超过 2 万建议先随机预降采样 dists torch.cdist(features, features) # [N, N] # 选距离均值中心最远的点作为第一个核心点 center_idx [int(torch.argmax(dists.mean(dim1)))] while len(center_idx) m: # 每个点到已选核心集的最短距离 min_dists dists[:, center_idx].min(dim1).values # 贪心选最远的点加入核心集 next_idx int(torch.argmax(min_dists)) center_idx.append(next_idx) return torch.tensor(center_idx)这个实现的复杂度是 O(m*N) 次距离查询加上一次性计算 NxN 距离矩阵的内存开销。sample_ratio是核心集占原始特征总数的比例MVTec AD 上常见取值 1%也就是说几万特征最后只保留几百个。工程上如果原始特征超过 2 万先做一次随机降采样到 1~2 万再跑 KCenterGreedy速度和内存都可控精度几乎无损。这是论文里也认可的做法不是偷懒。2.3 最近邻打分第 k 近邻距离作为异常分数核心集建好之后测试阶段对每个 patch 特征做最近邻搜索找到它在核心集里的近邻。但直接用最近邻距离当异常分数会被噪点干扰——某个 patch 可能恰好和核心集里一个离群点很近导致误判。PatchCore 的做法是用第 k 近邻距离默认 k9这样分数更稳定对孤立点不敏感。def compute_anomaly_score(feats, memory_bank, k9): # feats: [P, D]P 为测试图像的 patch 数 # memory_bank: [M, D]M 为核心集大小 dists torch.cdist(feats, memory_bank) # [P, M] # 取每个 patch 最近的 k 个距离 knn_dists, _ torch.topk(dists, kk, largestFalse) # 第 k 近邻距离作为该 patch 的异常分数 patch_scores knn_dists[:, -1] return patch_scorestorch.topk(..., largestFalse)取最小的 k 个距离[:, -1]取出其中最大的那个也就是第 k 近邻的距离。k 值调大分数更平滑但小缺陷可能被周围正常 patch“稀释”k 值调小定位更精细但容易出现椒盐噪点。经验值是 k9 配 sample_ratio1% 在 MVTec AD 上表现最稳。另外全图异常分数有两种聚合方式取所有 patch 分数的最大值或者取最大值加一个小权重乘以全局平均后者对大区域缺陷更敏感论文里权重w0.1是比较常见的配置。3. 完整实现流程从预处理到热力图叠加的逐步落地3.1 数据准备MVTec AD 直接可跑真实数据集要单独处理MVTec AD 按类别分目录训练集只有正常样本测试集包含正常和缺陷样本ground truth 是缺陷区域的像素级掩码。这个数据集不需要预处理读图后 resize 到统一尺寸就行。真实工业数据集则是另一回事常见问题包括图像尺寸不统一、缺陷区域标注缺失、光照和背景差异大。我一般会先做统计看训练集和测试集的图像尺寸分布与灰度分布再做定制预处理。预处理步骤操作适用场景填充黑色或边缘像素填充至正方形非正方形原图避免 resize 拉伸变形裁剪中心裁剪固定区域缺陷只出现在固定 ROI裁剪可去掉背景干扰resize统一到 256x256所有场景保证输入尺寸一致归一化使用 ImageNet 均值标准差配合预训练 backbone必须使用灰度转三通道单通道图复制成三通道灰度相机采集的工业图MVTec 的bottle、hazelnut等类别的原图分辨率不是统一的直接用不同分辨率进网络会让特征图尺寸不一致。我习惯统一 resize 到 256x256因为 PatchCore 默认分块数是基于 256 输入的。真实数据集如果缺陷区域占比很小先做中心裁剪缩小视野再 resize比直接 resize 整图效果好得多。from torchvision import transforms def get_train_transform(): return transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def get_test_transform(): # 测试阶段不做任何随机增强保证特征稳定 return transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])训练和测试的 transform 必须保持一致的 resize 逻辑。很多人在训练集里用 RandomCrop测试集用 CenterCrop最后热力图和原图对不上这是后文避坑章节的重点。归一化参数必须和预训练模型匹配ResNet50 的 ImageNet 权重对应的就是上面这组均值和标准差改了会影响特征的分布对齐。3.2 训练阶段构建核心集存储库训练阶段没有梯度回传本质上是把正常样本的特征提出来压缩成一个核心集存储库。完整流程是遍历训练集所有正常图像提取 patch 特征并拼接随机预降采样到 2 万以内跑 KCenterGreedy最终得到核心集并保存为.npy文件。import numpy as np import torch from torch.utils.data import DataLoader def build_memory_bank(model, dataloader, device, sample_ratio0.01): model.eval() feats_list [] with torch.no_grad(): for images, _ in dataloader: feats extract_patch_features(model, images.to(device)) feats_list.append(feats.cpu()) all_feats torch.cat(feats_list, dim0).squeeze(0) # [N, D] print(f原始特征数: {all_feats.shape[0]}) # 随机预降采样控制规模 if all_feats.shape[0] 20000: perm torch.randperm(all_feats.shape[0])[:20000] all_feats all_feats[perm] print(f预降采样后: {all_feats.shape[0]}) # KCenterGreedy 核心集采样 selected_idx kcenter_greedy(all_feats, sample_ratio) memory_bank all_feats[selected_idx] print(f核心集大小: {memory_bank.shape[0]}) # 保存为 numpy 文件后续测试直接加载 np.save(memory_bank.npy, memory_bank.numpy()) return memory_bank注意dataloader里如果 batch size 大于 1extract_patch_features返回的形状是[B, H*W, D]torch.cat后需要 reshape 成[B*H*W, D]。上面对 MVTec 单张推理的场景直接取了squeeze(0)换成真实数据集批量推理时需要改成all_feats.view(-1, all_feats.shape[-1])。保存核心集这一步很值得做因为训练阶段约等于一次特征提取几十秒就能跑完但测试阶段反复加载特征文件能省掉重复计算。3.3 测试阶段分块打分与热力图生成测试阶段对每个测试样本提取特征后直接和核心集算最近邻距离每个 patch 得到一个异常分数。分数重排回特征图尺寸上采样到原图尺寸就是缺陷热力图。最后用 OpenCV 的 COLORMAP_JET 上色再和原图加权融合得到直观的缺陷定位图。import cv2 import numpy as np def generate_anomaly_map(model, image_tensor, memory_bank, device, k9): model.eval() with torch.no_grad(): feats extract_patch_features(model, image_tensor.unsqueeze(0).to(device)) feats feats.squeeze(0) # [P, D] patch_scores compute_anomaly_score(feats, memory_bank, kk) # 重排成特征图尺寸注意特征图是 16x16 feature_h feature_w int(np.sqrt(patch_scores.shape[0])) score_map patch_scores.cpu().numpy().reshape(feature_h, feature_w) return score_map, patch_scores.cpu().numpy() def save_heatmap(orig_img, score_map, save_path, alpha0.6): h, w orig_img.shape[:2] # 双线性插值上采样到原图尺寸 score_map_resized cv2.resize(score_map, (w, h), interpolationcv2.INTER_LINEAR) # 归一化到 0-255 score_norm cv2.normalize(score_map_resized, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) # 应用 JET 色彩映射 heat cv2.applyColorMap(score_norm, cv2.COLORMAP_JET) blend cv2.addWeighted(orig_img, alpha, heat, 1 - alpha, 0) cv2.imwrite(save_path, blend)generate_anomaly_map里int(np.sqrt())是偷懒写法前提是输入是正方形且恰好整除。如果 resize 到 256特征图是 16x16没问题但真实数据如果做了非等比 resize 或者输入本身不是正方形这里会算错。稳妥的做法是直接从extract_patch_features返回特征图的 H 和 W一起带出来避免靠推断。alpha0.6表示原图占 60% 权重热力图占 40%缺陷区域会被标成红黄色正常区域基本保持原色。3.4 结果保存异常分数、热力图与叠加图作业和项目通常要求保存三类结果每个 patch 的异常分数、全图热力图、原图与热力图的叠加图。异常分数保存为.npy方便后续算 AUC 和画 ROC 曲线热力图和叠加图直接存成.png。我会建一个输出目录按类别和文件名组织方便自查和写报告。import os import json def save_results(save_dir, sample_name, patch_scores, score_map, orig_img, blend_path): os.makedirs(save_dir, exist_okTrue) # 保存 patch 级异常分数 np.save(os.path.join(save_dir, f{sample_name}_scores.npy), patch_scores) # 保存原始热力图数据 np.save(os.path.join(save_dir, f{sample_name}_map.npy), score_map) # 记录文件路径方便写报告时统一引用 with open(os.path.join(save_dir, results_index.json), a) as f: f.write(json.dumps({ name: sample_name, score_map: f{sample_name}_map.npy, blend: blend_path }) \n).npy格式保存的是浮点分数后续画 ROC、PR 曲线时直接读不需要重新跑推理。我见过有人把热力图 PNG 当分数用用像素颜色反推得分那是绕了一大圈还损失精度。正确流程始终是patch 分数存数值文件热力图只是可视化产物两条线分开走。4. 常见问题与避坑五个我踩过的 PatchCore 工程坑4.1 现象热力图和原图错位缺陷区域标歪了生成的热力图叠加到原图上缺陷位置偏移或者热力图边缘有奇怪的黑边。原因是测试阶段用了和训练阶段不同的 transform尤其是Resize和RandomCrop混用。训练时用随机裁剪、测试时用中心裁剪两张图输入网络时内容就已经对不上了特征图对应的空间位置自然对不齐。解决方法是训练和测试的 transform 完全一致都只用Resize加归一化。如果真实数据必须裁剪用固定坐标的中心裁剪并且热力图保存前要记录裁剪偏移量上采样后手动平移对齐。4.2 现象跑 KCenterGreedy 时内存直接 OOM核心集采样前torch.cdist(features, features)要算 NxN 的距离矩阵特征数 5 万时距离矩阵是 5 万 x 5 万个 float32占 10 GB 内存直接崩。原因是没做预降采样并且一次性计算整个距离矩阵。解决方法是先把特征随机降采样到 1~2 万再跑 KCenterGreedy如果 2 万仍然内存紧张改成批次计算距离每次只算当前候选点和已选核心集的距离不同时保留完整矩阵。这个方法会让采样变慢但内存占用从 O(N^2) 降到 O(N*M)M 是核心集大小。4.3 现象训练集里混入了缺陷样本漏检严重真实数据集标注不干净训练集里偶尔混进带缺陷的图。PatchCore 会把缺陷区域的特征也当成“正常”记忆存进核心集测试时真正缺陷的 patch 距离核心集很近异常分数很低漏检。一个有价值的自检手段是用建好的核心集给训练集本身打分看哪些训练样本的最大 patch 分数异常高。正常样本的分数应该集中在低值区间如果某张训练图的分数显著高于其他图大概率是混入了缺陷样本直接移除再重建核心集。4.4 现象k9 和 sample_ratio1% 在某些类别上同时失效在纹理类数据比如皮革、地毯上表现正常换到物体类比如螺丝、胶囊上热力图变成了全图高亮或者全图低亮区分度消失。原因是物体类数据中正常样本之间本身就存在较大的外观差异角度、光照、个体差异核心集过于稀疏时无法覆盖这些正常变化导致测试时正常区域也被判为异常。解决方法是针对物体类把sample_ratio从 1% 提到 5%同时把 k 从 9 降到 5。纹理类数据特征空间集中1% 够用物体类数据特征分散需要更多核心点。这个没有统一最优值按类别调参是常见做法。4.5 现象热力图定位准确但样本级 AUC 上不去热力图能看到缺陷区域但整图异常分数取最大值时正常样本上的一小块噪声也被当成缺陷导致样本级 AUC 虚高或虚低。根源是打分聚合方式不合理只取 max 对噪声敏感只取均值会稀释小缺陷。解决方法是使用论文里的复合分数max_score 0.1 * mean_score让大缺陷和局部小缺陷都能反映到最终分数上。这个 0.1 的权重是经验值如果数据噪声大可以降到 0.05如果大区域缺陷占比高可以升到 0.2。5. 评估与调参验证从 AUC 到可视化一套可复用的检查流程5.1 一个快速验证脚本拿到任何新数据集我第一件事不是调参而是跑一个最小验证闭环取一个类别、20 张训练图、10 张测试图把采样比例调到 10%数据量小不需要压缩跑通训练、打分、热力图生成、AUC 计算全流程。这样做能快速暴露代码层面的问题——路径错误、维度不匹配、归一化写错——而不是等全量训练跑完才发现。确认流程没问题后再按 1% 采样比例跑全量数据对比结果。from sklearn.metrics import roc_auc_score def evaluate_quick(data_loader, model, memory_bank, device, k9): scores, labels [], [] for images, targets in data_loader: score_map, patch_scores generate_anomaly_map( model, images[0], memory_bank, device, kk ) # 复合分数最大值 0.1 * 均值 final_score patch_scores.max() 0.1 * patch_scores.mean() scores.append(final_score) labels.append(targets[0].item()) return roc_auc_score(labels, scores)5.2 参数对比与 AUC 验证我按不同采样比例和 k 值组合跑了一组对比结论和论文一致采样比例从 1% 提到 10%样本级 AUC 提升有限0.5~1 个百分点但核心集大小涨 10 倍测试推理时间明显变长。k 的影响更微妙k9 比 k4 在纹理类上更稳物体类上差异不大。采样比例k核心集大小样本级 AUC推理时间1%9约 5000.9621.0x5%9约 25000.9682.3x10%9约 50000.9704.1x1%4约 5000.9541.0x1%15约 5000.9581.1x真实工业项目里精度和推理耗时的平衡点通常落在 1%~5% 采样比例加 k9。追求极致定位精度时用 5%追求产线实时检测时用 1%。从那以后我每次拿到新的缺陷数据集都强制先跑一遍最小验证闭环再决定采样比例和 k 值不会一上来就按默认参数全量跑。希望帮到你。本文还有配套的精品资源点击获取
返回列表