
简介基于特征嵌入的工业缺陷检测技术配套的PatchCore算法实践资料面向有一定编程基础的数据科学从业者、本科生和研究生适用于产品质检、图像处理等工程与教学场景。内容围绕基于重构与基于嵌入两条技术主线展开重点讲解特征提取、核心集采样、最近邻搜索与热力图生成等关键环节并给出数据预处理、模型训练、缺陷检测、像素级与样本级AUC评估、ROC与PR曲线绘制等可落地步骤同时包含评分标准、扩展建议与提交要求可直接用于课程大作业或项目入门参考。资源为1个PDF文件压缩包大小约192KB内容编排紧凑便于快速查阅。目前已有459人学习下载。如需结合MVTec AD等工业缺陷数据集完成实验可按其框架进行工程复现与参数调优。1. PatchCore到底解决什么问题工业缺陷检测里“特征嵌入”为什么是刚需做过工业缺陷检测的人都知道最难受的不是选型而是缺陷样本永远不够。产线上正常品有上百种外观但真正的划痕、脏污、漏焊只有几十张甚至只有标准样可用。传统分类网络碰到这种数据基本翻车。PatchCore走的是另一条路它不学“缺陷长什么样”而是用预训练网络把正常样本的局部特征嵌入到一个记忆库检测时拿新特征去比对距离越大越可能是缺陷。这就是基于特征嵌入的工业缺陷检测的核心逻辑。PatchCore的工程价值是一个类别只用几百张正常图不用标缺陷框训练阶段没有反向传播只是提取特征和采样推理做一次最近邻查找硬件要求低。它特别适合纹理检测、金属玻璃表面微小瑕疵、新品快速打样。但优点有条件特征嵌入选层、记忆库压缩、距离聚合参数稍微调偏AUROC就可能崩。这篇文章我就按完整复现PatchCore的思路从原理讲到可跑通的代码再列踩过的坑。2. 特征嵌入与PatchCore算法原理从patch特征到记忆库的最短路径要真正复现PatchCore必须理解特征嵌入怎么来、记忆库怎么建、距离怎么算。我按这个顺序讲。PatchCore不是黑匣子它的每一步都可以用代码验证。2.1 为什么用预训练特征嵌入而不是直接训练一个CNN分类器在工业场景里缺陷类型是开放的。一条生产线上可能的缺陷有几十种不能提前定义。传统分类器需要所有缺陷类别有足够标注这在工业现场根本凑不齐。即使凑齐了新换一个产品型号旧模型基本作废。更麻烦的是很多缺陷只有在特定光照、特定角度下才可见用有限的数据训一个分类器它的判断依据往往是背景而不是缺陷本体。PatchCore选择了预训练特征嵌入作为主干。用ImageNet预训练的Wide ResNet-50在不需要更新参数的前提下提取每张图的中间层特征。因为这些特征是在海量自然图像上训练出来的对边缘、纹理、局部形状有很强的表达能力。对工业表面即使没有见过这种材质低层和中层特征依然能描述“这个局部区域长得是否和见过的正常样本一样”。“特征嵌入”这个词听起来学术实际上就是把输入图像映射成低维向量。但PatchCore用的不是整图一个向量而是一组patch特征。每一个空间位置的向量负责描述图像的一个局部。一个划痕、一个凹点会影响附近少数patch的向量而整图特征可能只被轻微扰动不好检测。所以patch级特征嵌入是PatchCore能在小缺陷上拿高AUROC的主要原因。对比传统特征工程LBP、HOG、SIFT特征嵌入的优势是不用手动设计并且可以迁移到新的产品。如果你换一个表面材质LBP的窗口参数、HOG的细胞大小可能要重新调而预训练CNN的中层特征基本可以直接复用。当然这不是免费的后续你会看到它对输入尺寸和归一化非常敏感。2.2 PatchCore三步流程特征提取、coreset采样、最近邻距离一句话理解PatchCore正常样本的特征做完代表压缩后存起来新样本的特征去库里找最近邻找不到就说明有异常。拆开是三步。第一步是特征提取。把每张正常图像resize到统一尺寸做ImageNet归一化输入Wide ResNet-50。取layer2和layer3两个中间层的输出将layer3上采样到和layer2相同分辨率然后拼起来。这样每个空间位置都有一个高维特征向量对应原图上一个“patch”。这里的patch不是像素块而是卷积感受野覆盖的区域。第二步是coreset采样。把所有正常图像的patch特征拼接成一个大矩阵行数是patch数量列数是特征维度。一张256x256的图像layer2是28x28拼接后约784个patch特征。训练集中如果有1000张图全量就有78万个向量每个向量1536维。直接全量存推理时每次查询都要扫78万行内存和延迟都不乐观。coreset用贪心算法选出一小部分代表性向量尽量覆盖整个特征空间的边界和内部区域。第三步是最近邻距离。测试图像的每个patch特征在记忆库中找最近的向量算一个距离。所有patch的距离按某种方式聚合例如取最大距离作为图像异常分数。正常patch在库里一定有一个近邻距离小缺陷patch不在库的覆盖范围距离大。这个距离不需要训练直接可解释。下面给coreset的一个简化实现帮助理解之后参数调优时的直觉# coreset采样把全量patch特征压缩成记忆库 import numpy as np def coreset_sample(features, num_samples): features: (N, D) 全量patch特征N通常很大 num_samples: 想压缩到的记忆库条目数 返回: (num_samples, D) n features.shape[0] indices [0] # 从第一个点开始 min_dist np.full(n, np.inf) # 记录每个点到已选集合的最小距离 while len(indices) num_samples: cur features[indices[-1]] # 计算当前点与所有点的欧氏距离 dist np.linalg.norm(features - cur, axis1) # 更新每个点到已选集合的最小距离 min_dist np.minimum(min_dist, dist) # 选择最小距离最大的点作为下一个代表点 nxt np.argmax(min_dist) indices.append(nxt) return features[np.array(indices)]逻辑说明第1行到第2行选的最远点能覆盖特征空间边界避免代表点全挤在密集区。用欧氏距离是因为patch特征本身有尺度信息归一化反而会丢失缺陷与正常之间的对比度。参数说明num_samples越大记忆库越完整但内存和搜索耗时线性增长。常见做法是取全量特征的1%~10%。工业上如果全量特征100万取1万到3万基本够用。严格FPS的复杂度是O(N*M)N很大时会很慢所以实践上我通常先随机抽一个子集再跑FPS后面代码里会体现。2.3 Coreset采样与距离度量的两个隐藏细节第一个细节是FPS的复杂度。上面的代码每轮选点要计算一次全量距离如果有50万patch、选1万个代表点Python循环要跑1万次每次算50万维距离非常慢。常见做法是先用随机采样把全量压到5万以内再在这5万上跑FPS速度会快很多代表点质量只差一点点。如果你的服务器内存足够也可以直接用K-Means聚类中心代替FPS但聚类中心是虚拟点不是真实patch推理时最近的向量可能没有物理对应物距离语义会有一点偏移。第二个细节是距离度量。PatchCore原文使用的是欧氏距离不要为了“归一化”把所有特征做成单位向量再算余弦距离。工业表面上正常样本的patch特征尺度本身携带纹理深浅、灰度对比的信息L2归一化会把这种全局强度差异抹掉。举个例子一块金属表面正常区域如果天然有亮暗变化归一化后这些变化会被当成同一件事反而会放过真正由于光照异常造成的脏污。我一般保留原始欧氏距离只在特征提取时用ImageNet的mean/std做数据归一化。3. 可复现的PatchCore代码复现环境准备与特征提取脚本这一章进入可以照着敲的步骤。我默认使用PyTorch依赖很少。这里给出的是我习惯的目录结构和预训练特征提取代码。3.1 最小环境依赖torch、torchvision与数据路径建议先建虚拟环境避免把系统Python搞乱。python -m venv patchcore_env source patchcore_env/bin/activate pip install numpy torch torchvision scikit-learn tqdm说明这里只需要torch和torchvision提供Wide ResNet-50的预训练权重不需要自己实现backbone。torchvision的weight接口在0.13以后用weightsDEFAULT更安全后面代码会这样写。如果只有CPU环境也能跑通只是特征提取慢一些工业现场如果只有工控机CPU跑推理也是可接受的只要记忆库别太大。3.2 数据准备MVTec AD目录结构与自己数据集的组织方式MVTec AD是工业异常检测最常见的公开数据集每个类下有train、test、ground_truth三个目录。train里只有good子目录全是正常样本test里有good和若干缺陷子目录如scratch、hole等。你需要把数据组织成类似结构哪怕用自己的数据也建议保留train/good这样的形式。下面是一个只加载正常样本的Datasetimport torchvision.transforms as T from torch.utils.data import Dataset from PIL import Image from pathlib import Path IMG_SIZE 256 mean (0.485, 0.456, 0.406) std (0.229, 0.224, 0.225) transforms T.Compose([ T.Resize((IMG_SIZE, IMG_SIZE)), T.ToTensor(), T.Normalize(mean, std) ]) class MVTecTrainDataset(Dataset): def __init__(self, root): self.paths list(Path(root).glob(train/good/*.png)) self.transform transforms def __len__(self): return len(self.paths) def __getitem__(self, idx): img Image.open(self.paths[idx]).convert(RGB) return self.transform(img)说明两点。第一训练集只加载train/good不要加入任何缺陷样本。哪怕只有一张缺陷图混进训练集都会让记忆库把缺陷当成正常范围后续这一类缺陷几乎全部漏检。第二这里没有做随机裁剪、翻转等数据增强。PatchCore不需要增强增强反而会制造不存在的正常纹理拉大正常分布让缺陷距离变小。3.3 用Wide ResNet-50提取中间层特征hook挂载与输出拼接PatchCore用layer2和layer3的拼接特征。torchvision的wide_resnet50_2可以直接加载预训练权重然后用hook捕获中间层输出。import torch import torch.nn.functional as F from torchvision.models import wide_resnet50_2, Wide_ResNet50_2_Weights class PatchFeatureExtractor: def __init__(self, devicecuda): self.device device self.backbone wide_resnet50_2(weightsWide_ResNet50_2_Weights.DEFAULT) self.backbone.eval().to(device) self.features {} self.backbone.layer2.register_forward_hook(self._make_hook(layer2)) self.backbone.layer3.register_forward_hook(self._make_hook(layer3)) def _make_hook(self, name): def hook(module, input, output): self.features[name] output return hook torch.no_grad() def __call__(self, x): self.features.clear() _ self.backbone(x) f2 self.features[layer2] # (B, 512, H2, W2) f3 self.features[layer3] # (B, 1024, H3, W3) # 把layer3上采样到与layer2一致便于按位置拼接 f3 F.interpolate(f3, sizef2.shape[-2:], modebilinear, align_cornersFalse) return torch.cat([f2, f3], dim1)逻辑说明这里没有去掉模型的最后一层forward会继续跑到fc层白费一些算力但hook已经在layer2、layer3触发不影响结果。如果要在产线上降低提取耗时可以只保留到layer3之前但需要自己构造截断模型这个不是必须。参数说明layer2输出512通道layer3输出1024通道拼接后每个patch特征1536维。输入256x256时layer2是28x28layer3是14x14上采样后再拼空间位置对齐每个位置对应一个patch。尺寸越大patch数量越多记忆库越大。常见做法是固定256不要轻易加大到512否则一张图的patch数会变成784*4倍推理时间不可控。4. PatchCore训练与推理实践记忆库构建、阈值设置与评估这一章直接给出完整流程。训练阶段没有梯度只是提取特征并采样推理阶段是最近邻距离和AUROC评估。这里的代码我已经在办公室机器上跑通过把容易踩的坑一起说清。4.1 训练把正常样本的特征压缩成记忆库训练集所有正常图经过特征提取后得到N个1536维patch特征。全量直接存的话78万个向量约占用4.7GB普通机器勉强能放但推理时每次搜索会很慢。所以我使用“先随机抽子集再FPS”的两步策略。import numpy as np import torch from torch.utils.data import DataLoader device cuda if torch.cuda.is_available() else cpu extractor PatchFeatureExtractor(device) dataset MVTecTrainDataset(root./mvtec_anomaly_detection/) loader DataLoader(dataset, batch_size16, shuffleFalse, num_workers4) all_patches [] for imgs in loader: patch_feats extractor(imgs.to(device)) # (B, 1536, H, W) b, c, h, w patch_feats.shape patch_feats patch_feats.permute(0, 2, 3, 1).reshape(-1, c) all_patches.append(patch_feats.cpu().numpy()) all_patches np.concatenate(all_patches, axis0) print(f全量patch特征: {all_patches.shape}) # 避免全量FPS太慢先随机抽5万再在子集上做FPS np.random.seed(0) if len(all_patches) 50000: subset_idx np.random.choice(len(all_patches), 50000, replaceFalse) subset all_patches[subset_idx] else: subset all_patches # 子集的5%通常够用相当于全量样本中取一层代表 mem_size max(100, int(len(subset) * 0.05)) memory_bank coreset_sample(subset, mem_size) np.save(memory_bank.npy, memory_bank) print(f记忆库大小: {memory_bank.shape})逻辑说明patch_feats的shape是(B, C, H, W)我们把它permute成(B, H, W, C)再reshape成(BHW, C)就是把每个空间位置展平成一个特征向量。记忆库保存为npy文件后续推理直接load。参数说明mem_size先按子集的5%取如果全量78万、子集5万mem_size就是2500代表点偏少但足够跑通。如果追求精度把子集放大到10万mem_size按10%取推理时间会上升AUROC可能涨一个点。这个要基于自己的验证集调。4.2 推理计算AUROC的完整脚本测试集需要返回图像和标签。MVTec的test目录下good子目录标签是0其它缺陷子目录标签是1。为了逐图计算图像级分数推理时batch_size设成1最稳妥。import torchvision.transforms as T from torch.utils.data import DataLoader, Dataset from PIL import Image from pathlib import Path class MVTecTestDataset(Dataset): def __init__(self, root): self.paths [] self.labels [] for defect_dir in sorted(Path(root, test).iterdir()): is_good defect_dir.name good for img_path in defect_dir.glob(*.png): self.paths.append(img_path) self.labels.append(int(not is_good)) self.transform transforms # 使用3.2中定义的那个transforms def __len__(self): return len(self.paths) def __getitem__(self, idx): img Image.open(self.paths[idx]).convert(RGB) return self.transform(img), self.labels[idx] test_dataset MVTecTestDataset(root./mvtec_anomaly_detection/) test_loader DataLoader(test_dataset, batch_size1, shuffleFalse, num_workers4)评估脚本如下from sklearn.metrics import roc_auc_score extractor PatchFeatureExtractor(device) memory_bank np.load(memory_bank.npy).astype(np.float32) scores [] labels [] for img, y in test_loader: patch_feats extractor(img.to(device)) # (1, 1536, H, W) b, c, h, w patch_feats.shape patchs patch_feats.permute(0, 2, 3, 1).reshape(-1, c).cpu().numpy().astype(np.float32) # 分块计算每个patch到记忆库的最近邻距离避免一次性矩阵过大 min_dists [] chunk_size 1024 for s in range(0, len(patchs), chunk_size): end min(s chunk_size, len(patchs)) chunk patchs[s:end] dist np.linalg.norm(chunk[:, None, :] - memory_bank[None, :, :], axis-1) min_dists.append(dist.min(axis1)) min_dists np.concatenate(min_dists) scores.append(min_dists.max()) # 图像级异常分数取patch最大距离 labels.append(int(y.item())) auroc roc_auc_score(labels, scores) print(f图像级AUROC: {auroc:.4f})逻辑说明代码里chunk[:, None, :] - memory_bank[None, :, :]是一次广播做外层减法得到(chunk_size, mem_size, 1536)的距离矩阵再在axis1取最小。分块是为了防止内存溢出。参数说明图像级分数用max对缺陷大小不敏感但如果测试图有噪声单个噪点patch也会拉高分数。另一个选择是对min_dists排序取前5的平均能抗噪但会削弱单点小缺陷。我建议先跑max如果误检率高再换top-5。AUROC只反映排序质量实际产线还要看阈值对应的误检率后面会讲。4.3 必调参数coreset比例、图像尺寸、特征层选择下面这张表是我在MVTec和几类工业项目上调参的常用起点参数我常用值调整方向影响图像尺寸256降到192提速升到384提细节越大patch越多记忆库膨胀搜索变慢特征层layer2layer3只取layer3或加layer1层越浅分辨率越高、语义越弱层越深语义强、分辨率低coreset比例1%~2%0.5%提速5%提精度低于0.5%会漏长尾特征高于5%收益很小距离聚合maxtop-5平均max对小缺陷敏感top-5抗噪声输入归一化ImageNet均值方差不要换预训练模型期望这个分布注意参数不能照搬。MVTec是标准光源环境工业现场如果光照变化大图像尺寸和coreset比例都要重新试。另外PatchCore没有“训练步数”概念所以每次增加正常样本只需要重新提取特征并合并采样不用重训模型。5. PatchCore实战避坑五个最容易让AUROC崩掉的地方代码复现本身不难但想复现到论文水平下面这些坑我基本都趟过。每条按“现象-原因-解决”给出来。5.1 现象一同样的参数AUROC比公开基线低5个百分点原因是预处理数值不对。很多人直接用PIL的ToTensor后减自己算的mean但预训练模型要求ImageNet的mean(0.485,0.456,0.406)、std(0.229,0.224,0.225)。还有人先切图再resize或者训练集和测试集用了不同插值方式都会导致特征分布偏移。解决检查数据管线的每一步确保训练和测试共用同一个transforms对象不要在类内部重新定义mean/std。5.2 现象二coreset比例调高到10%性能反而下降原因是记忆库太大以后每一个测试patch都能在库里找到非常近的正常邻居异常距离被“稀释”。尤其当正常样本本身含有少量灰尘、环境反射时更大的记忆库会把杂讯也纳入正常分布使得缺陷距离被淹没。解决不要一味调高比例。先用1%试如果AUROC不足再逐步增加同时检查训练数据是否干净。如果训练集混入异常加大记忆库只会固化污染这个反直觉点很容易让人在调参时翻车。5.3 现象三推理时内存溢出或者一次距离计算卡死原因是torch.cdist或numpy广播把全部patch和记忆库一次性构建成矩阵。假设测试图784个patch记忆库5万个向量784500001536*4字节约240MB看起来不大但如果你默认用float64内存会翻倍而且计算会非常慢。解决分块计算每块不超过1024个patch把特征和记忆库都转成float32。另外一个技巧是把记忆库先预加载到内存不要每次推理时重新从磁盘读否则I/O时间会超过计算时间。5.4 现象四像素级定位完全不可用掩码是一团糊原因是图像级检测只取了max距离没有保留每个patch的最近邻距离矩阵。定位需要每个patch的距离图而layer3特征被上采样后虽然patch数量与layer2一致但实际空间分辨率还是28x28直接resize到256会模糊。解决推理时保存min_dists的二维矩阵而不是只取max。定位时把28x28的得分图用双线性插值上采样到原图尺寸再做阈值分割。插值别用最近邻会出方块效应。5.5 现象五AUROC看起来不错但产线上误检率依然很高原因是公开数据集测试集是人工挑选的标准场景工业现场有光照、角度、遮挡正常样本分布更宽。PatchCore对“未见过的正常状态”也会判为异常。解决上线前用正常工况采集一段现场视频提取每一帧的patch特征合并进记忆库。如果担心污染可以先跑一次推理把距离较大的正常样本挑出来人工确认后加入。这一步看起来简单但往往比调模型参数更关键。6. 把PatchCore再推进一步像素级定位与加速的工程技巧6.1 用距离图做像素级缺陷定位推理时把每个patch的最小距离存成二维矩阵就能得到粗糙的定位图。以下是我在项目里的做法# min_dist_map shape: (28, 28)对应输入256x256 import cv2 import numpy as np score_map cv2.resize(min_dist_map, (256, 256), interpolationcv2.INTER_LINEAR) thresh np.percentile(score_map, 95) # 根据训练集距离分布定 mask (score_map thresh).astype(np.uint8) * 255逻辑说明28x28的得分图对一块15x15的缺陷可能只影响两三个patch所以上采样后得分是阶梯状的需要配合形态学开闭运算。参数说明阈值不要直接用固定值我会先统计训练集所有patch距离的99分位作为初始阈值再根据现场误检率微调。这个方法的精度不如per-pixel监督模型但不依赖任何缺陷标注适合快速打样。6.2 加速用近似最近邻替换精确搜索当记忆库超过3万条精确搜索的延迟会明显变大。工业产线上如果每张图要控制在50毫秒以内我会用faiss的IndexFlatL2建索引或者用HNSW。IndexFlatL2就是暴力索引但底层矩阵计算比numpy广播快很多HNSW是图索引适合记忆库特别大的场景但构建时参数efConstruction和efSearch需要调。常见做法是先用IndexFlatL2验证检索质量确认没有丢精度再切HNSW。加速后一张图的patch距离计算可以从几百毫秒压到几十毫秒。我自己的习惯是每次换数据集先跑20%训练样本统计全量patch数量再决定coreset比例。这个习惯帮我避开很多返工。PatchCore最迷人的地方在于没有训练过程每一步都能解释但这也意味着所有脏数据、错误预处理都会直接反应在记忆库里。希望帮到你。本文还有配套的精品资源点击获取