拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

基于特征嵌入的工业缺陷检测:PatchCore原理与代码复现

基于特征嵌入的工业缺陷检测:PatchCore原理与代码复现 简介这是一份面向工业缺陷检测课程大作业的PDF资料围绕基于嵌入的PatchCore算法展开适合需要完成相关实验或入门缺陷检测的本科生、研究生及数据科学从业者。文档共1个文件大小约192KB内容覆盖作业背景、算法原理、数据准备、模型训练与测试流程、异常热力图生成、像素级与样本级评估指标并附评分标准与可选扩展方向。目前已有459人学习或下载。借助该资料可快速理解PatchCore中特征提取、核心集采样与最近邻搜索的核心思想获得从实验设计、参数调优到课程报告撰写的完整框架参考。文档中针对MVTec AD及真实工业数据给出了数据预处理与整体流程设计思路对分块大小、重叠比例等关键参数的影响亦有专门讨论并提供了ROC曲线、PR曲线以及像素级与样本级AUC等评估方法。这些内容既能支撑课程作业完成也能迁移到实际产品质量检测场景中。1. 基于特征嵌入的工业缺陷检测PatchCore 从原理到代码复现无损检测里有个反直觉的事实PatchCore 从头到尾没有训练过神经网络它只拿预训练模型做特征提取然后对正常样本的特征空间做一次聪明的“压缩存储”。这个思路被 MVTec AD 上的实验反复验证过——只用正常样本做参考库就能在纹理、物体、金属等类别上拿到接近甚至超过端到端训练模型的像素级 AUC。这个资源包的核心就是把这套流程拆成可复现的代码特征提取、核心集采样、最近邻搜索、热力图生成全部模块化直接跑 MVTec AD也能换自己的真实工业缺陷数据。适合正在做课程设计或工业质检项目、需要快速落地一个无监督异常检测方案的读者尤其是那些已经试过重构类方法Autoencoder/VAE但被“泛化到正常区域”问题困扰的人。2. PatchCore 的三个关键模块特征提取、核心集采样与最近邻检索2.1 为什么基于嵌入的方法优于基于重构的方法基于重构的缺陷检测思路很直观用正常样本训练一个自编码器让它学会重建正常图像的形态。测试时把待检图像送进去再对比重建输出和原图的差异差异大的地方就是缺陷。问题在于神经网络普遍具备很强的泛化能力——如果重建模型容量过大或训练不充分它会把异常区域也“脑补”成正常形态导致缺陷漏检。PatchCore 走的是另一条路完全不训练直接用 ImageNet 预训练的 Wide ResNet-50 做特征提取器把每张正常图像切成重叠的小块提取每个位置的特征向量构成一个“正常特征库”。测试图像同样切块提取特征然后逐个找特征库里最近邻距离远的地方就是异常。这套方案能成立有三个原因。第一预训练模型在 ImageNet 上学到的底层视觉特征边缘、纹理、局部形状具有通用性工业缺陷本质上也是这些局部特征的异常偏移。第二正常样本的特征分布是紧凑的异常样本的特征会偏离这个分布最近邻距离能自然量化这种偏差。第三它绕开了“重构误差被模型容量支配”的黑匣子问题所有计算都是确定性的参数含义清晰调参方向明确。2.2 核心集采样为什么必要显存与速度的权衡如果直接存储所有正常样本的块级特征MVTec AD 中一个类别大约能产生几十万到上百万个特征向量。例如 bottle 类别的训练集有 209 张 512×512 图像在特征尺度 28×28 下会产生超过 16 万个块特征。最近邻搜索时每个测试块都要和全部特征计算余弦距离或欧氏距离这会带来两个问题内存占用过高推理速度过慢。Coreset Sampling 的核心思想是从原始特征集中选出一个子集使得子集中每个特征向量都能在原始集合的某个特征向量附近指数为 ε 的覆盖半径内。这样一来子集可以近似代表整个特征空间。通常特征量 1%相对于原始特征集约 1000 个特征就能把正常样式的多样性保留下来。KCenterGreedy 是经典贪心近似算法从特征集中随机选第一个中心每轮计算剩余特征到已选中心集合的最小距离取最小距离最大的特征加入集合。重复直到达到预算。def kcenter_greedy(features, budget_ratio0.01, bucket_size1000): KCenterGreedy 核心集采样 参数: features: (N, D) 特征矩阵, N 为块特征总数 budget_ratio: 采样比例, 例如 0.01 表示从 N 中保留约 1% bucket_size: 分桶大小, 控制每个桶内并行的点数 返回: coreset_indices: 选中特征的索引列表 selected_scores: 每个选中特征与已选集合的最小距离(可选) n_samples features.shape[0] budget max(int(n_samples * budget_ratio), 1) # 先把特征集随机分成若干桶, 避免一次性求 NxN 距离矩阵 perm torch.randperm(n_samples) features features[perm] n_buckets (n_samples bucket_size - 1) // bucket_size # 第一步: 从全部特征中随机抽取一个作为起始中心 selected_ids [] selected_features [] # 用分桶法计算所有点到初始中心的距离, 贪心选择第一个中心 min_dists torch.full((n_samples,), float(inf)) # 这里简化: 直接选第一个特征作为起始中心(也可以随机) selected_id 0 selected_ids.append(selected_id) selected_features.append(features[selected_id]) for _ in range(budget - 1): # 分桶计算每个特征到已选中心集合的最小距离 for i in range(n_buckets): start i * bucket_size end min(start bucket_size, n_samples) bucket features[start:end] # (桶大小, D) selected_feat_tensor torch.stack(selected_features) # 计算桶内特征与已选中心的距离 dist torch.cdist(bucket, selected_feat_tensor).min(dim1).values # 更新全局最近距离 min_dists[start:end] torch.min(min_dists[start:end], dist) # 选择离当前集合最远的特征 farthest_idx torch.argmax(min_dists).item() if min_dists[farthest_idx] float(inf): break selected_ids.append(farthest_idx) selected_features.append(features[farthest_idx]) min_dists[farthest_idx] 0 # 返回原始索引 return perm[selected_ids].tolist()这段代码里有几个容易被忽略的点。bucket_size的设置是为了避免一次torch.cdist计算(N, N)的完整距离矩阵——特征量为 20 万时,torch.cdist一次就会爆显存, 分桶后每个桶只有(bucket_size, D), 峰值内存大幅下降。min_dists的初始值是无穷大, 表示“尚未被任何中心覆盖”, 每次迭代后更新为“到最近中心的距离”, 这使得贪心算法每轮都能选中离现有集合最远的特征。选中后把该位置的距离置为 0, 防止后续被重复选择。在实际实验中发现, KCenterGreedy 在高维空间下非常耗时。特征数 10 万、特征维度 1024 时, 一轮需要计算(bucket_size, 1024)与(k, 1024)的距离矩阵, 当 k 增长到几百后, 单轮计算量会显著增大。因此建议在代码层面做几个优化只计算全集的采样预算比例, 不追求精确的全局最优可以把特征先做 PCA 降维到 128 维再采样, 保留主要几何关系的同时显著加速。2.3 最近邻搜索与图像级异常分数的聚合方式PatchCore 最终输出的异常分数是逐块的。每个测试块提取特征后, 在核心集特征库中寻找最近邻, 记录欧氏距离或余弦距离。为得到图像级分数, 常见做法是对所有块距离取最大值或分位数。有一点需要注意PatchCore 论文里用的特征包含局部特征和全局特征两部分。局部特征来自 Wide ResNet-50 的中间层输出, 全局特征来自对最后一层特征做全局平均池化得到的 1×1 特征, 两者拼接后组成 1248 维向量1024 维中间层特征 224 维全局池化特征。测试时, 要保证局部特征经过相同的预处理后, 才能与核心集的距离度量有意义。图像级分数聚合时, 取所有块分数的最大值往往过于敏感, 单个噪声块很容易推高分数取均值则容易淹没小尺寸缺陷。经验做法是取 90 分位或直接取 max, 在工业场景中考虑到漏检后果比过检大, 取最大值更符合安全预期。实验报告里可以同时给出 max 和 p90 两种聚合方式的样本级 AUC, 这本身就是有价值的实验对比。3. 数据准备与预处理好坏直接影响实验结论的可信度3.1 MVTec AD 无需预处理但“不做预处理”有三个前提MVTec AD 本来就是为无监督异常检测设计的标准数据集, 所有图像都是 512×512 或接近该分辨率的高质量图, 缺陷区域清晰, 尺度统一。但“不做预处理”不是直接Image.open就喂进模型, 还要做三件基础工作统一缩放到模型输入尺寸归一化到 ImageNet 的均值和标准差从训练集只取good类别的图像用于构建核心集class MVTecDataset(Dataset): def __init__(self, root_path, category, split): self.data [] self.transform transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) ]) image_dir os.path.join(root_path, category, split) for cls in os.listdir(image_dir): # 训练集只加载 good 类别 if split train and cls ! good: continue cls_path os.path.join(image_dir, cls) for img_name in os.listdir(cls_path): img_path os.path.join(cls_path, img_name) self.data.append((img_path, 0 if cls good else 1)) def __len__(self): return len(self.data) def __getitem__(self, idx): path, label self.data[idx] img Image.open(path).convert(RGB) img self.transform(img) return img, label, path这段代码的含义集中在三点transforms.Resize((256, 256))把不同原图尺寸统一实际实验中 256 和 512 的选择会影响最终热力图分辨率512 推理时间翻倍但能保留更细的缺陷边缘Normalize用 ImageNet 统计值是必须的因为 Wide ResNet-50 的预训练权重是在这个归一化分布下学的代码里split train只取good类别测试集则保留所有类别用于评估。类别标签里把good映射为 0、缺陷映射为 1后续计算样本级 AUC 时这个二分类标签直接可用。3.2 真实工业缺陷数据需要做定制预处理MVTec AD 作为基准数据干净但真实产线的图像通常是另一番景象光照不稳定、存在镜头畸变、待检物位置有偏移、背景中有无关物体。这些干扰如果直接进入特征提取阶段会被当成正常特征的一部分导致误检率升高。常见处理思路是按“ROI 提取 → 图像对齐 → 裁剪填充 → 统一分辨率”的顺序来。def preprocess_industrial_image(img, template_size(512, 512)): 真实工业图像定制预处理 流程: 1. 基于模板匹配找到待检区域(ROI) 2. 对 ROI 做透视校正, 消除角度和位移偏差 3. 统一填充/裁剪到指定尺寸 if img.shape[0] 2000 or img.shape[1] 2000: # 大图先降采样, 加速模板匹配 img cv2.resize(img, (img.shape[1] // 2, img.shape[0] // 2)) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 用一张标准图截取 ROI 作为模板 template cv2.imread(templates/roi_template.png, 0) res cv2.matchTemplate(gray, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc cv2.minMaxLoc(res) h, w template.shape roi img[max_loc[1]:max_loc[1]h, max_loc[0]:max_loc[0]w] # 填充/裁剪到统一尺寸, 保持长宽比 scale min(template_size[0] / roi.shape[1], template_size[1] / roi.shape[0]) new_w int(roi.shape[1] * scale) new_h int(roi.shape[0] * scale) resized cv2.resize(roi, (new_w, new_h)) canvas np.zeros((template_size[1], template_size[0], 3), dtypenp.uint8) offset_x (template_size[0] - new_w) // 2 offset_y (template_size[1] - new_h) // 2 canvas[offset_y:offset_ynew_h, offset_x:offset_xnew_w] resized return canvas一个必须注意的细节是“背景对齐”。如果待检物在每次图像中的位置都有偏移, 直接用原图做切块特征提取, 会把背景纹理的变化也记入异常分数。用模板匹配把 ROI 裁出来再送进算法, 能显著降低背景干扰。实际操作时, 把模板存成单独文件而不是每次从代码里生成, 有利于保持多批次图像处理的一致性。3.3 预处理阶段的坑分辨率选择和真实数据的 ground truth做实验时最容易踩的坑是分辨率选择。PatchCore 论文里使用 512×512 作为输入分辨率, 但很多复现代码默认 256×256。分辨率下降后, 特征图尺寸从 28×28 降到 14×14, 每个块对应的原图像素范围变大, 小缺陷在热力图上会被平均掉。真实工业数据集中更“真实”的坑是 ground truth 不会给你——评估像素级 AUC 必须自己有标注。MVTec AD 官方提供了二值分割掩码gt, 但真实产线上通常只有“合格/不合格”的样本级标签, 没有像素级标注。常用的做法是人工标注一批代表性缺陷图像, 或者用 LabelMe 批量画掩码如果实在无法标注, 像素级评估只能在有标注的少量样本子集上做, 并在报告里注明统计口径。4. 训练阶段实现特征提取与核心集构建的完整代码4.1 预训练特征提取器为什么选 Wide ResNet-50 而不是 ResNet-50PatchCore 论文里比较过多种骨干网络, 包括 ResNet-50、Wide ResNet-50、EfficientNet 和 ViT。结论是 Wide ResNet-50 在 MVTec AD 上综合表现最好。原因在于宽残差网络在同样的深度下, 每层通道数更多, 保留了更丰富的局部纹理特征对于细小的划痕、凹坑等缺陷, 这种高频信息比深层语义更重要。代码实现时直接使用 torchvision 里预训练权重, 但需要注意从哪个层取特征。PatchCore 官方做法是取layer2[-1]之后的特征图, 具体来说是layer2的最后一个 Bottleneck 输出, 尺寸为输入图像的1/4。以 256×256 输入为例, 特征图尺寸为 64×64, 再经过torch.nn.functional.avg_pool2d(kernel_size3, stride2, padding1)得到 32×32 的局部特征。全局特征则是对layer3的输出做全局平均池化。import torch import torch.nn as nn import torch.nn.functional as F from torchvision.models import wide_resnet50_2, Wide_ResNet50_2_Weights class PatchCoreFeatureExtractor(nn.Module): def __init__(self, backbonewide_resnet50_2): super().__init__() if backbone wide_resnet50_2: self.model wide_resnet50_2( weightsWide_ResNet50_2_Weights.IMAGENET1K_V1 ) else: raise ValueError(fUnsupported backbone: {backbone}) # 取 layer2 之后与 layer3 之后的特征 self.layer2 nn.Sequential( self.model.conv1, self.model.bn1, self.model.relu, self.model.maxpool, self.model.layer1, self.model.layer2 ) self.layer3 nn.Sequential( self.model.layer3 ) # 固定预训练权重, 不需要反向传播 for param in self.parameters(): param.requires_grad False def forward(self, x): 提取局部特征与全局特征 参数: x: (B, 3, H, W) 归一化后的图像批次 返回: local_features: (B, C_l, h, w) 局部特征图 global_feature: (B, C_g) 全局池化特征 feat2 self.layer2(x) # (B, 512, H/4, W/4) feat3 self.layer3(feat2) # (B, 1024, H/8, W/8) local_features F.avg_pool2d( feat2, kernel_size3, stride2, padding1 ) # (B, 512, H/8, W/8) global_feature F.adaptive_avg_pool2d(feat3, (1, 1)) global_feature global_feature.view(x.size(0), -1) # (B, 1024) return local_features, global_feature代码里两个层的输出含义不同。layer2输出 512 通道的局部特征图, 以 256 输入为例尺寸为 32×32, 每个位置对应原图的 8×8 像素区域。layer3输出 1024 通道, 尺寸为 16×16, 进行全局平均池化后得到 1024 维的全局描述子。最终每个图像块的局部特征维度是 512, 与全局特征拼接后维度为 1536。4.2 构建特征库切块、特征拼接与核心集压缩从训练集构建特征库的流程是遍历所有正常训练图像, 通过提取器得到局部特征图, 把局部特征图的所有位置特征向量取出, 和该图的全局特征向量拼接, 汇总为一个大矩阵。然后将这个大矩阵送入 KCenterGreedy 采样器。def build_feature_bank(train_loader, extractor, device, feature_dim1536): 构建正常样本特征库并执行核心集采样 参数: train_loader: 仅含 good 样本的 DataLoader extractor: PatchCoreFeatureExtractor device: cuda / cpu 返回: coreset: (M, feature_dim) 核心集特征矩阵, M N extractor.eval() feature_bank [] with torch.no_grad(): for batch, _, _ in train_loader: batch batch.to(device) local_feat, global_feat extractor(batch) # local_feat: (B, C, h, w) - 展平成 (B*h*w, C) B, C, h, w local_feat.shape local_feat local_feat.permute(0, 2, 3, 1) local_feat local_feat.reshape(B * h * w, C) # 每个位置都拼接上当前图像的 global 特征 global_feat_expand global_feat.unsqueeze(1).expand( B, h * w, -1 ).reshape(B * h * w, -1) block_feat torch.cat([local_feat, global_feat_expand], dim1) feature_bank.append(block_feat.cpu()) feature_bank torch.cat(feature_bank, dim0) print(fTotal features: {feature_bank.shape[0]}, dim: {feature_bank.shape[1]}) # KCenterGreedy 采样 coreset_indices kcenter_greedy( feature_bank, budget_ratio0.01, bucket_size1000 ) coreset feature_bank[coreset_indices] return coreset代码里第 26 行的global_feat_expand对应了前面提到的局部全局拼接方式。每个局部块特征都重复拼接当前图像的整体描述, 这样最近邻检索时不仅比较局部纹理差异, 还比较位置上下文。这样做能有效区分“局部分块纹理正常、但位置不对”的缺陷。核心集采样完成后, 特征库大小会从几十万压缩到几千。保存方式建议用torch.save(coreset, coreset.pth), 并在旁边保存一份meta_info.json, 记录特征维度、采样比例、输入分辨率和骨干网络类型。模型推理时加载这些信息, 避免之后想调参时忘了当初用的什么配置。4.3 训练阶段参数设置采样比例与分析建议核心集采样比例是超参数里影响最大的一个。比例太高会引入噪声特征, 比例太低则正常样式的多样性不足, 缺陷会被误判为正常。论文里推荐 1%, 实际实验中可以把 0.01、0.1、0.5、1 四档都跑一遍, 记录各自的图像级 AUC。采样比例与检测性能的关系通常不是单调的。缺陷类型是大面积表面纹理异常时, 采样比例稍低反而表现好, 因为特征库只保留最核心的正常纹理模式, 与缺陷区域的差异更容易拉大缺陷是小而稀疏的点状凹坑时, 采样比例需要高一些, 才能保证参考特征多样性覆盖正常区域的所有位置。MVTec AD 里的 hazelnut 和 metal nut 就分别是这两类情况的代表案例。推理速度方面, 核心集特征数从 10 万压缩到 1000, 最近邻搜索的复杂度会下降两个数量级。测试阶段每个块特征只需与 1000 个向量计算距离, 512×512 输入下全图 65536 个块, 单张推理在 GPU 上通常 0.5 秒内完成。5. 推理与热力图生成特征比对到缺陷可视化的完整流程5.1 测试阶段重叠分块、镜像推理与最近邻搜索推理阶段的目标是生成与输入图像同尺寸的异常热力图。每个像素的异常分数来自该位置所在块的特征与其在核心集中的最近邻距离。为了让热力图更平滑, 必须使用重叠分块——每 4 个像素的步长采样一个块, 而不是按特征图原始分辨率硬切。def compute_patch_scores(test_img, extractor, coreset, device): 对单张测试图像计算逐像素异常分数 参数: test_img: (1, 3, 256, 256) 归一化图像 coreset: (M, 1536) 核心集特征矩阵 返回: anomaly_map: (256, 256) 浮点数热力图 extractor.eval() with torch.no_grad(): test_img test_img.to(device) local_feat, global_feat extractor(test_img) B, C, h, w local_feat.shape # 重叠分块: 特征图尺寸 h x w, 按窗口大小 s 划分 # 取 s 16, 步长 4, 对应原图 4 倍下采样 s 16 stride 4 num_patches_h (h - s) // stride 1 num_patches_w (w - s) // stride 1 score_map torch.zeros(h, w, devicedevice) count_map torch.zeros(h, w, devicedevice) global_feat_expand global_feat.unsqueeze(-1).unsqueeze(-1) for i in range(num_patches_h): for j in range(num_patches_w): y0, x0 i * stride, j * stride patch local_feat[:, :, y0:y0s, x0:x0s] # 池化补丁内的特征到一个向量 patch F.adaptive_avg_pool2d(patch, (1, 1)).squeeze(-1).squeeze(-1) # (B, C) patch torch.cat([patch, global_feat], dim1) # (B, CC_g) dist torch.cdist(patch, coreset.to(device)).min(dim1).values # dist: 单个数值 # 将距离填回该窗口覆盖区域 score_map[y0:y0s, x0:x0s] dist count_map[y0:y0s, x0:x0s] 1 score_map score_map / count_map.clamp(min1) # 上采样到原图尺寸 anomaly_map F.interpolate( score_map.unsqueeze(0).unsqueeze(0), size(test_img.shape[-2], test_img.shape[-1]), modebilinear, align_cornersFalse ).squeeze(0).squeeze(0) return anomaly_map.cpu()这段代码里需要注意三个点。窗口s16对应原图中16×464像素的方形区域, 步长stride4对应原图 16 像素, 因此相邻窗口有大量重叠, 热力图是平滑过渡的。adaptive_avg_pool2d后每个窗口内特征被池化为单向量, 等效于把多位置特征取均值, 能抑制局部噪声。score_map / count_map.clamp(min1)是为了剔除边缘位置未被任何窗口覆盖的可能性——窗口滑不满图像边缘, 如果不做除法, 边缘的异常分数会被低估。5.2 热力图上采样与原图叠加异常分数图是一个低分辨率的矩阵256 输入时尺寸为 32×32, 必须插值到原图分辨率才能与原始图像叠加显示。使用双线性插值输出浮点型热力图, 再用cv2.applyColorMap转换为伪彩色, 与原图按权重混合。def visualize_anomaly(img, anomaly_score, alpha0.4): 将异常热力图叠加到原图 参数: img: (H, W, 3) 原始 RGB 图像(0-255) anomaly_score: (H, W) 浮点数异常分数 alpha: 热力图透明度 返回: overlay: 叠加后的可视化图像 # 归一化到 0-255 并转换伪彩色 score_norm (anomaly_score - anomaly_score.min()) / \ (anomaly_score.max() - anomaly_score.min() 1e-6) score_uint8 (score_norm * 255).astype(np.uint8) heatmap cv2.applyColorMap(score_uint8, cv2.COLORMAP_JET) overlay cv2.addWeighted(img, 1 - alpha, heatmap, alpha, 0) return overlay由于max()和min()会受到极端值影响, 一个稳健的做法是使用 95 分位和 5 分位来代替最大值最小值。工业图像中偶发的高亮噪声块会把热力图其他区域压得很暗, 用分位归一化能缓解这个问题。5.3 图像级异常分数如何聚合前面逐像素的热力图适合可视化, 但评估样本级 AUC 还需要一个单值分数。把整张热力图的像素级异常分数取最大值, 或者取前 5% 高分像素的平均值, 都能作为图像级异常分数。实验中发现, 取torch.quantile(anomaly_map, 0.95)在大多数类别上比 max 更稳定——max 对单个像素的噪声过于敏感, 而 95 分位关注“严重异常区域”而不被单点噪声主导。5.4 最近邻检索的实现选择嵌套循环扫描每个测试块的距离在 CPU 上效率偏低实际操作中用torch.cdist(test_features, coreset)一次批量计算, 然后再取min。MVTec AD 测试集单类有几百张图, 全图块特征数量在千万级时, 直接cdist可能显存溢出。解决办法是分块批量检索def nearest_neighbor_scores(test_feats, coreset, chunk_size50000): 分块最近邻检索, 避免显存溢出 参数: test_feats: (P, D) 测试块特征 coreset: (M, D) 核心集特征 返回: scores: (P,) 每个块到核心集最近的距离 scores [] num_chunks (test_feats.shape[0] chunk_size - 1) // chunk_size for i in range(num_chunks): chunk test_feats[i * chunk_size: (i 1) * chunk_size] dist torch.cdist(chunk.to(cuda), coreset.to(cuda)) scores.append(dist.min(dim1).values.cpu()) return torch.cat(scores)chunk_size50000是经验值, 需要根据 GPU 显存调整。24GB 显存下可以适当调大到 200000, 8GB 显存下建议缩小到 20000。6. 实验评估与避坑要点6.1 像素级与样本级 AUC 的计算方法PatchCore 实验报告需要同时给出两组指标。样本级 AUC 容易理解以“图像属于缺陷与否”为二分类标签, 以图像级异常分数为分类分, 计算 ROC 曲线下面积。像素级 AUC 则要逐像素判断——测试图像的 ground truth 掩码为 1 的位置对应真缺陷像素, 热力图中对应位置的分数是预测的异常程度, 所有像素汇总后计算 AUC。def compute_pixel_auc(anomaly_map, mask): 像素级 AUC 参数: anomaly_map: (H, W) 热力图分数 mask: (H, W) 二值掩码, 1 表示缺陷像素 返回: auc: 浮点数 from sklearn.metrics import roc_auc_score scores anomaly_map.flatten() labels mask.flatten() # 去掉背景像素, 或保留全部像素均可 auc roc_auc_score(labels, scores) return auc需要注意的一点是, MVTec AD 的 ground truth 掩码里缺陷区域为 255, 不是 1。读入后要先除以 255 或做threshold处理。另一个常见问题是像素级 AUC 在缺陷很小、正样本像素占比极低时曲线形态会被背景像素主导此时需要调整评估区域的策略——可以限制在 ROI 范围内计算, 避免 ROI 外的大量背景像素干扰。6.2 PatchCore 复现的 5 个高频踩坑点坑 1特征提取层选错, 导致热力图质量崩塌现象热力图非常粗糙, 缺陷区域几乎看不出位置。原因从layer1输出提取特征导致感受野过小, 特征图尺寸过大而语义不够抽象。解决严格使用layer2[-1]之后的平均池化特征, 尺寸为输入的 1/8 左右。坑 2核心集采样内存溢出现象torch.cdist报显存不足。原因没有分桶计算。解决将特征集切分为bucket_size1000的桶, 每个桶内和已选中心做距离计算, 再全局聚合。代码已在前文给出。坑 3全局特征拼接后嵌入距离失真现象缺陷检测准确率远低于论文报告值。原因局部特征与全局特征拼接时没有做归一化, 全局特征1024 维的数值尺度远大于局部特征512 维, 距离度量几乎被全局特征主导。解决拼接前分别做 L2 归一化, 或者对距离做加权组合dist w1*dist_local w2*dist_global, 实验中取w10.6, w20.4比较稳定。坑 4测试阶段特征提取没有关闭 BatchNorm 和 Dropout现象同一张图多次推理得到不一致的异常图。原因Wide ResNet-50 的 BatchNorm 在训练模式下用 batch 内的统计量, 测试时应使用全局统计量。解决显式调用extractor.eval(), 并且torch.no_grad()包裹。坑 5分块重叠比例选择不当现象热力图出现明显的棋盘格纹理。原因stride大于窗口尺寸导致相邻窗口之间没有重叠, 异常分数在窗口边界处跳变。解决s16, stride4的组合保证了 4 倍重叠, 如果使用更大的窗口, 也要保持stride s/2。6.3 实验参数表模板参数取值影响输入分辨率256×256 / 512×512512 保留更多缺陷细节, 耗时约 4 倍特征层layer2 (局部) layer3 (全局池化)局部感受野 8x8, 全局语义描述采样比例0.01特征库从 10 万压缩到 1000, AUC 下降小于 0.5%分块窗口s16对应原图 64×64, 兼顾细节与鲁棒性重叠步长stride44 倍重叠, 消除块边界效应图像级分数max / p95max 对噪声敏感, p95 更稳定6.4 权重衰减、阈值选取比调更高 AUC 更重要的事很多人复现 PatchCore 把精力花在调整模型结构上其实在大多数缺陷类型下影响更大的是阈值的选择。工业场景里 “漏检” 和 “误检” 的成本不一样不能只看 AUC。AUC 高不代表在实际产线上可用还需要确定一个具体的判定阈值。常见做法是在验证集上先跑通全流程, 固定采样比例和窗口大小, 再调阈值使得误检率控制在可接受范围, 从而得到实际部署的判定边界。6.5 真实数据和 MVTec AD 的差距所在最关键的差异是数据分布的稳定性。MVTec AD 同一类别下图像背景、光照、角度都严格统一, PatchCore 只在特征空间里做分布比较, 所以效果好。真实产线上设备换了一个镜头、光照角度变了 10 度, 整个特征分布就会平移, 之前采的特征库可能就失效了。这个现象我遇到过一次用 A 产线采集的数据做了核心集, 换到 B 产线, 检测误报率直接翻了三倍。从那以后我每次做 PatchCore 项目都强制走一遍“产线变更确认清单”光源是否更换、相机是否重新标定、待检物是否有新的型号版本、采集的图像是否已经加入新的正常样本。这几条如果不满足, 再优秀的特征嵌入也会被环境变化击穿。这篇资源里提供了完整的特征提取与核心集采样实现、热力图生成与评估代码如果你的项目也卡在采样效率或真实数据预处理上建议先从采样流程和特征归一化开始对照排查通常会找到问题所在希望帮到你。本文还有配套的精品资源点击获取
返回列表