1. 从“找茬”到“找不同”:工业质检的难题与PatchCore的破局
在工厂的生产线上,质检员每天要盯着成千上万的零件、产品,用肉眼去发现那些微小的划痕、凹坑、色差或者异物。这活儿干久了,不仅眼睛累,还容易因为疲劳而漏检。所以,用AI视觉系统来替代或辅助人工,就成了一个必然的趋势。但这事儿说起来容易做起来难,最大的一个坎儿就是“冷启动”问题。
什么叫“冷启动”?简单说,就是你的AI模型只能看到“好”的产品图片,那些“坏”的、有缺陷的图片,要么数量极少,要么压根就没有。你没法像训练一个猫狗分类器那样,给模型看一大堆“正常”和“异常”的图片让它去学。这就好比你要教一个新人质检,但你只能给他看完美无瑕的样品,然后告诉他:“记住这些好的样子,以后看到跟这些不一样的,就是坏的。”这听起来是不是有点强人所难?早期的很多方法,比如自编码器或者生成对抗网络,就是试图让模型“记住”正常样本的样子,然后去“重建”测试图片。如果重建出来的图片跟原图差别很大,就说明原图里有模型“没见过”的异常。但这种方法有个问题,它重建的是整张图,对于一些微小的、局部的缺陷,模型可能会“脑补”成正常的纹理,导致漏检。
这时候,PatchCore的思路就非常巧妙了。它不纠结于重建整张图,而是把注意力放在了图像的“局部补丁”上。你可以把一张高清的产品图想象成一块由无数个小瓷砖(补丁)拼成的马赛克画。PatchCore的核心思想是:只要有一个小瓷砖的样子,在之前所有“好”的样本马赛克画里都找不到相似的,那整张画大概率就是有问题的。 这个思路非常符合人的直觉——我们找缺陷,往往也是先聚焦在某个可疑的局部。
PatchCore通过构建一个庞大的“补丁特征记忆库”,把正常样本里所有小瓷砖的“特征指纹”都存起来。当检测新产品图片时,就把它拆成小瓷砖,挨个去记忆库里“比对指纹”。找不到匹配的,就标记为异常。这个方法直击要害,绕过了复杂的图像重建,直接把异常检测转化成了一个在特征库里的“最近邻搜索”问题。我最早在产线上尝试这个方法时,最直观的感受就是,它对那些细小的、对比度不高的缺陷特别敏感,而这恰恰是传统方法和人眼最容易忽略的地方。
2. 庖丁解牛:拆解PatchCore的三大核心组件
理解了PatchCore“以局部鉴全局”的哲学后,我们来看看它具体是怎么实现的。整个流程可以清晰地拆解为三个关键步骤,就像一套组合拳,拳拳到肉。
2.1 记忆库构建:给所有“好瓷砖”建立特征档案
第一步,也是最重要的基础工作,就是构建那个庞大的“正常补丁特征记忆库”。这个过程听起来吓人,其实原理很清晰。
首先,你需要一堆确认无缺陷的正常产品图片作为训练集。然后,用一个在ImageNet等大型数据集上预训练好的深度神经网络(比如WideResNet50)作为“特征提取器”。这里有个关键点:PatchCore通常选择网络中间层的特征。为什么不直接用最后输出的特征呢?因为底层特征太细节(比如边缘、线条),缺乏语义信息;高层特征又太抽象(比如“这是一个电路板”),会丢失缺陷所在的空间位置信息。中层特征正好是个折衷,它既包含了有意义的纹理、图案信息,又保留了足够的空间分辨率,能让我们精准定位到缺陷发生在图像的哪个“瓷砖”上。
具体操作时,我们把一张正常图片输入网络,提取出其中间层的特征图。这个特征图你可以理解为一张“压缩版”的图片,它的每个位置(比如一个3x3的格子)都对应着原图某个区域的一个高维特征向量,这个向量就是那个“瓷砖”的“特征指纹”。我们通过滑动窗口的方式,把整张特征图上所有位置的特征向量都收集起来。对所有正常图片都这么操作一遍,然后把收集到的海量特征向量堆在一起,就构成了最初的记忆库。
我举个例子,假设你检测的是手机外壳,训练集有1000张正常图片,网络中层特征图的空间大小是28x28。那么,你最终会得到一个包含 1000 * 28 * 28 = 784,000 个特征向量的记忆库。每个向量都代表了一块“好瓷砖”的独特身份信息。
2.2 贪婪核集子采样:给记忆库“瘦身”的智慧
一个包含几十万甚至上百万个特征向量的记忆库,直接用来做比对,计算量是惊人的,推理速度会慢到无法满足产线实时检测的需求。而且,这个库里有大量冗余信息——很多“瓷砖”的特征其实非常相似。这就引出了PatchCore第二个精妙的设计:贪婪核集子采样。你可以把它理解为给记忆库做一次“智能压缩”或“精选”。
它的目标是从庞大的原始记忆库中,挑选出一个尽可能小的子集(比如1万个特征),要求这个子集能“代表”原始库的绝大部分信息。怎么挑呢?PatchCore采用了一种贪婪策略:
- 首先,随机选一个特征向量作为核集的起点。
- 然后,在剩下的特征中,总是挑选那个距离当前核集中所有向量最远的那个特征加入核集。换句话说,就是找那个“最与众不同”的。
- 重复步骤2,直到核集达到我们预设的大小。
这个策略的直觉是:我们要用最少的“代表”,覆盖住整个特征空间。优先选择边角上的、与众不同的点,就能用更少的点勾勒出特征空间的轮廓。经过这么一番操作,记忆库的大小可能被压缩到原来的1%甚至更少。我在实际项目中测试过,对一个78万的特征库进行子采样,保留1%的特征(7800个),最终的检测精度(AUC)下降通常不到0.5%,但推理速度却能提升几十倍。这种用极小的性能损失换取巨大的效率提升,在工业场景里简直是“神技”。
2.3 异常评分与定位:如何判断并找到那块“坏瓷砖”
记忆库建好并瘦身后,就可以上线检测了。对于一张待测的图片,我们同样提取其中层特征,并拆分成一个个补丁特征向量。
对于待测图片的每一个补丁特征,我们到“瘦身”后的核集记忆库里去寻找与它最相似(欧氏距离最近)的那个“正常特征”。它们之间的距离,就是这个补丁的“异常分数”。距离越大,说明这个补丁越“反常”。
那么,整张图片的异常如何判断呢?PatchCore采用了一个非常直接且有效的策略:取所有补丁异常分数中的最大值。只要有一个地方特别异常,就认为整张图异常。这非常符合工业质检“宁可错杀,不可放过”的严苛要求,也就是论文标题里强调的“Towards Total Recall”(追求全召回,尽量不漏检)。
更酷的是,由于我们是在特征图的空间位置上计算每个补丁的分数,我们可以轻松地将这些分数映射回原图对应的位置,生成一张“异常热力图”。图上越亮(分数越高)的区域,就是越可疑的缺陷位置。这为后续的维修、复检提供了精准的指导。在实际的软件界面上,操作员不仅能看到“合格/不合格”的判断,还能直接看到热力图上一个高亮的小点指着那个微小的划痕,体验非常好。
3. 实战指南:手把手搭建你的第一个PatchCore检测器
理论说了这么多,不上手试试都是空谈。下面我就用一个简化版的示例,带你走一遍代码流程。我们以Python为基础,使用PyTorch框架。
3.1 环境准备与依赖安装
首先,确保你的环境里有Python(3.8以上)和PyTorch。我强烈建议使用Anaconda来管理环境,避免包冲突。
# 创建一个新的conda环境
conda create -n patchcore_demo python=3.9
conda activate patchcore_demo
# 安装PyTorch(请根据你的CUDA版本去官网选择对应命令)
# 例如,对于CUDA 11.8
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
# 安装其他必要的库
pip install opencv-python pillow scikit-learn matplotlib numpy tqdm
接下来,我们需要一个预训练模型。这里我们使用 torchvision 自带的 WideResNet50。
3.2 核心代码模块解析
我们主要实现三个函数:特征提取、核集子采样、异常检测。
1. 特征提取函数: 这个函数负责从单张图片中提取我们需要的中间层特征块。
import torch
import torchvision.models as models
from torchvision import transforms
from PIL import Image
import numpy as np
def extract_features(image_path, model, layer_name='layer2'):
"""
从指定层提取特征块。
image_path: 图片路径
model: 预训练模型
layer_name: 要提取特征的网络层名
"""
# 定义图像预处理
preprocess = transforms.Compose([
transforms.Resize((256, 256)), # 统一缩放到固定大小
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
img = Image.open(image_path).convert('RGB')
img_tensor = preprocess(img).unsqueeze(0) # 增加batch维度
# 注册钩子来获取中间层输出
features = {}
def get_features(name):
def hook(model, input, output):
features[name] = output.detach()
return hook
# 获取指定层
layer = dict([*model.named_modules()])[layer_name]
handle = layer.register_forward_hook(get_features(layer_name))
# 前向传播(不计算梯度)
with torch.no_grad():
_ = model(img_tensor)
handle.remove() # 移除钩子
# features[layer_name] 的形状是 [1, C, H, W]
# 我们将其重塑为 [H*W, C],即一系列特征向量
feature_map = features[layer_name].squeeze(0) # [C, H, W]
C, H, W = feature_map.shape
# 将空间位置展平,每个位置是一个C维特征
patch_features = feature_map.permute(1, 2, 0).reshape(-1, C) # [H*W, C]
return patch_features.numpy() # 转为numpy数组方便后续处理
2. 贪婪核集子采样函数: 这个函数实现前面说的“智能压缩”算法。
from sklearn.metrics import pairwise_distances
def greedy_coreset_subsampling(features, target_size):
"""
贪婪核集子采样。
features: 原始特征库,形状 [N, D]
target_size: 目标核集大小
"""
N, D = features.shape
indices = np.arange(N)
selected_indices = []
# 第一步:随机选择一个初始点
first_idx = np.random.randint(0, N)
selected_indices.append(first_idx)
coreset = features[first_idx].reshape(1, -1) # 初始核集
# 迭代选择剩余点
for _ in range(1, target_size):
# 计算剩余每个点到当前核集的最小距离
# 这里为了效率,可以分批计算,演示用简单写法
min_distances = []
for i in range(N):
if i in selected_indices:
min_distances.append(-1) # 已选的点标记为-1
continue
# 计算该点到核集中所有点的距离,取最小值
dists = pairwise_distances(features[i].reshape(1, -1), coreset)
min_distances.append(dists.min())
# 选择距离最大的那个点
next_idx = np.argmax(min_distances)
selected_indices.append(next_idx)
# 将该点加入核集
coreset = np.vstack([coreset, features[next_idx].reshape(1, -1)])
return features[selected_indices], np.array(selected_indices)
3. 异常检测与评分函数: 这个函数处理测试图片,并给出最终结果。
def detect_anomaly(test_features, coreset):
"""
计算测试图片的异常分数和热力图。
test_features: 测试图片的特征块,形状 [M, D]
coreset: 核集记忆库,形状 [K, D]
返回: 最大异常分数,每个块的异常分数(热力图数据)
"""
# 计算测试特征到核集中每个点的距离,取最小值
# 使用向量化运算提高效率
# 这里简化为循环示意,实际应用应使用广播或矩阵运算优化
anomaly_scores = []
for test_vec in test_features:
distances = np.linalg.norm(coreset - test_vec, axis=1) # 计算到核集所有点的欧氏距离
min_distance = distances.min()
anomaly_scores.append(min_distance)
anomaly_scores = np.array(anomaly_scores)
image_level_score = anomaly_scores.max() # 图像级分数取最大值
return image_level_score, anomaly_scores
3.3 完整流程串联与效果验证
现在,我们把上面的模块串起来,模拟一个完整的流程。
# 1. 加载预训练模型
model = models.wide_resnet50_2(pretrained=True)
model.eval() # 切换到评估模式
# 假设我们有一个正常图片列表
normal_image_paths = ['normal_1.jpg', 'normal_2.jpg', ...] # 你的正常样本路径
# 2. 构建初始记忆库
memory_bank = []
for img_path in normal_image_paths:
feats = extract_features(img_path, model, layer_name='layer2')
memory_bank.append(feats)
memory_bank = np.vstack(memory_bank) # 形状 [Total_Patches, Feature_Dim]
print(f"原始记忆库大小: {memory_bank.shape}")
# 3. 贪婪核集子采样
target_coreset_size = 1000 # 目标保留1000个特征
coreset, selected_idx = greedy_coreset_subsampling(memory_bank, target_coreset_size)
print(f"核集记忆库大小: {coreset.shape}")
# 4. 测试阶段
test_image_path = 'test_product.jpg'
test_features = extract_features(test_image_path, model, layer_name='layer2')
# 5. 检测异常
image_score, patch_scores = detect_anomaly(test_features, coreset)
print(f"测试图像异常分数: {image_score}")
# 6. 设置阈值并判断(阈值需要根据验证集确定)
threshold = 1.5 # 这是一个示例阈值,需要你根据实际数据调整
if image_score > threshold:
print("检测到异常!")
# 可以将patch_scores重塑为热力图显示
# 假设特征图空间大小是H, W
H, W = 16, 16 # 这取决于你提取的特征图层大小
heatmap = patch_scores.reshape(H, W)
# 使用matplotlib显示热力图
import matplotlib.pyplot as plt
plt.imshow(heatmap, cmap='hot')
plt.colorbar()
plt.title('Anomaly Heatmap')
plt.show()
else:
print("图像正常。")
这段代码提供了一个非常清晰的骨架。在实际工业项目中,你需要考虑更多细节,比如多尺度特征融合、更高效的距离计算(如使用Faiss库进行最近邻搜索)、阈值的自适应确定、以及处理高分辨率大图时的滑动窗口策略等。但万变不离其宗,核心就是这三板斧:建库、瘦身、比对。
4. 避坑指南与性能调优:来自产线的实战经验
纸上得来终觉浅,绝知此事要躬行。在好几个实际工业项目中落地PatchCore后,我踩过不少坑,也总结出一些能让效果和效率更上一层楼的调优技巧。
4.1 常见问题与解决方案
问题一:误报过高(把好的判成坏的)。 这是初期最常见的问题。可能的原因和解决办法:
- 特征层选择不当:尝试使用更底层的特征(如
layer1)或更高层的特征(如layer3)。layer2是一个不错的起点,但对于纹理特别复杂或缺陷特别微小的场景,可能需要调整。我的经验是,缺陷尺寸越小,越倾向于使用较低层的特征,因为高层特征可能已经将其平滑掉了。 - 核集大小太小:核集压缩得太狠,丢失了太多正常模式的多样性。尝试逐步增加
target_coreset_size,观察验证集上的性能变化,找到一个平衡点。通常,保留原始特征库的1%-5%是个合理的范围。 - 训练集“正常”样本不纯:这是最致命也最容易被忽视的问题。你的训练图片里可能混入了肉眼难以察觉的轻微缺陷。务必对训练集进行最严格的清洗,甚至可以考虑用多个模型交叉验证来筛选。
- 图像预处理不一致:训练和测试时的图像缩放、归一化参数必须严格一致。一个像素值的偏差在特征空间里可能会被放大。
问题二:漏报(没检测出缺陷)。
- 缺陷与背景对比度极低:PatchCore依赖特征差异,如果缺陷和周围正常纹理在颜色、亮度上几乎没区别,任何方法都困难。这时可以考虑在输入模型前,对图像进行一些增强对比度的预处理(如CLAHE),或者尝试在多个颜色空间(如HSV的S、V通道)上分别提取特征。
- 阈值设置得太高:阈值不能拍脑袋决定。应该在一个包含已知正常和异常样本的验证集上,绘制ROC曲线,根据你对误报和漏报的容忍度(比如要求召回率Recall > 99%)来选取合适的阈值。
问题三:推理速度慢,无法满足实时性。
- 优化距离计算:上面示例代码中的循环计算距离是性能瓶颈。务必使用Faiss库。Faiss是Facebook开源的专门用于高效相似性搜索和稠密向量聚类的库,支持GPU加速。将核集记忆库构建成Faiss的IndexFlatL2索引,批量计算测试特征的距离,速度能有百倍提升。
import faiss index = faiss.IndexFlatL2(coreset.shape[1]) # L2距离索引 index.add(coreset.astype('float32')) # 添加核集到索引 # 批量搜索 D, I = index.search(test_features.astype('float32'), k=1) # k=1找最近邻 anomaly_scores = D[:, 0] # 距离即为异常分数 - 减少测试图片的补丁数量:对于大图,不一定非要每个像素位置都提取补丁。可以增大提取特征时的步长(stride),或者在特征图上进行自适应池化来降低空间分辨率,用更少的补丁代表整张图。
4.2 高级调优技巧
- 多尺度特征融合:不要只拘泥于某一层。可以同时提取
layer2和layer3的特征,将它们在通道维度上拼接起来,或者分别计算异常分数后再融合。这样能同时利用中层的位置信息和高层的语义信息,对大小不一的缺陷更鲁棒。 - 局部邻域聚合:原始PatchCore论文中提到了一个细节,在构建记忆库时,每个补丁的特征并不是直接从特征图的一个点上取的,而是聚合了周围一个小邻域(比如3x3)的特征。这相当于给每个“瓷砖”加上了一点上下文信息,能让特征更稳定,对噪声不那么敏感。实现上,可以在提取特征后,用一个平均池化层来实现。
- 记忆库的动态更新:产线上的设备、光照可能会有缓慢变化。可以设计一个安全机制,对于连续多批被判定为正常且置信度很高的产品,将其特征经过严格筛选后,增量地加入到记忆库中,让模型能够适应缓慢的“漂移”。但这需要非常谨慎的设计,避免引入错误。
最后,我想强调一点,PatchCore是一个强大的工具,但它不是银弹。它的效果严重依赖于预训练模型的特征提取能力。对于某些极其特殊的工业材质(比如特殊的金属光泽、复杂的透明物体),ImageNet预训练模型的特征可能迁移性不够好。这时,你可能需要考虑在少量正常数据上对特征提取网络进行微调,或者探索领域自适应的预训练方法。不过,在绝大多数常见的工业视觉场景中,PatchCore以其简洁的构思、出色的效果和高效的性能,已经成为了无监督异常检测领域一个绕不开的标杆。当你下次面对只有“好”样本的检测难题时,不妨试试这套“记住所有好瓷砖,然后找不同”的思路,它可能会给你带来惊喜。
40



被折叠的 条评论
为什么被折叠?



