1. 工业质检场景下的异常检测困局
做过工业视觉质检的人大概都有过这种体验:产线上良品样本堆积如山,缺陷样本却少得可怜,有时候跑了一整天可能就抓到那么三五张有瑕疵的图。更麻烦的是,缺陷的种类还在不断变化——今天可能是划痕,明天变成气泡,后天又冒出个从来没见过的脏污。你拿着一堆正常样本和零星几个缺陷样本去训分类模型,训出来的东西基本没法看,换条产线、换个批次就得推倒重来。
这就是异常检测这个方向存在的意义。它的核心设定跟传统分类任务完全不同:训练阶段只给你正常样本,模型要学会的是“正常长什么样”,推理阶段任何偏离这个正常分布的东西都算异常。听起来简单,但实际做起来,难点在于如何定义“正常”的边界,以及如何在没有异常样本参与训练的前提下,让模型对从未见过的缺陷类型也能敏感。
PatchCore 就是在这个背景下被提出来的。它属于基于特征嵌入的一类方法,核心思路是用预训练网络提取正常样本的局部特征,构建一个特征记忆库,推理时拿测试图像的特征去记忆库里做最近邻搜索,距离超过阈值就判为异常。这个思路本身不算新鲜,SPADE、PaDiM 这些前辈都走过类似的路,但 PatchCore 在几个关键细节上做了改进,使得它在 MVTec AD 这个工业异常检测基准上拿到了非常漂亮的成绩,同时推理速度也控制在了工业场景可接受的范围内。
我第一次接触 PatchCore 是在一个表面缺陷检测的项目里。当时试过基于重构的方法(AutoEncoder 那一套),也试过基于分类的方法(把正常样本当负类硬训),效果都不太理想。重构方法的问题是,有时候缺陷区域也能被重构得不错,导致漏检;分类方法的问题是,正常样本太多导致极度不平衡,模型学不到有判别力的边界。后来换成 PatchCore,在只调了少量参数的情况下,AUROC 直接比之前的方法高了将近 8 个百分点,而且推理速度满足产线节拍要求。从那以后,PatchCore 就成了我做工业异常检测的默认 baseline。
这篇文章我会从原理层面把 PatchCore 拆开讲清楚,包括它为什么这样设计、每个模块解决了什么问题、实际复现时有哪些坑。适合有一定深度学习基础、正在做或准备做工业异常检测的读者。如果你只是想知道怎么调包跑通,网上有很多现成的代码仓库;但如果你想理解背后的设计逻辑,以便在自己的场景里做针对性改进,那这篇内容应该能帮到你。
2. 从特征嵌入到记忆库:PatchCore 的核心设计逻辑
2.1 为什么不用重构误差而用特征距离
基于重构的异常检测方法(AutoEncoder、VAE、GAN 那一类)的逻辑是:用正常样本训练一个重构模型,模型学会了只重构正常样本,遇到异常样本时重构误差会变大。这个逻辑听起来很合理,但实际用起来有个致命问题——有时候异常区域也能被重构得不错,尤其是当异常区域比较小或者跟正常区域纹理接近的时候。原因是重构模型是在像素空间做优化的,而像素空间的差异并不总能反映语义层面的异常。
PatchCore 换了个思路:不在像素空间比较,而是在预训练网络提取的特征空间里比较。具体来说,用一个在 ImageNet 上预训练好的网络(比如 WideResNet50)作为特征提取器,把正常样本的局部特征存起来,推理时看测试图像的特征跟这些存储特征的距离。这个思路的好处是,预训练网络已经学到了丰富的语义特征,对纹理、形状、颜色等各种变化都有一定的鲁棒性,而且特征空间里的距离比像素空间里的距离更能反映语义层面的差异。
这里有个关键认知:PatchCore 不训练特征提取网络,它直接用预训练权重。这意味着你不需要准备大量数据来训一个 backbone,也不需要担心过拟合。整个方法的核心工作量在于如何构建和查询那个特征记忆库。
2.2 局部特征与全局特征的取舍
PatchCore 用的是局部特征而不是全局特征。什么意思呢?假设输入一张 224x224 的图像,经过 backbone 的某个中间层后得到一个 HxWxC 的特征图,每个空间位置上的 C 维向量就是一个局部特征,对应原图上的一个感受野区域。PatchCore 把这些局部特征全部收集起来,而不是像分类任务那样做一个全局池化得到一个 C 维向量。
为什么要用局部特征?因为工业缺陷通常是局部的——一道划痕、一个气泡、一块脏污,它们只影响图像的一小部分区域。如果用全局特征,整张图的特征会被正常区域主导,缺陷信号被稀释,导致漏检。用局部特征的话,每个位置独立判断,缺陷区域的特征跟记忆库里的正常特征距离大,就能被检测出来。
但局部特征也有代价:特征数量太多了。一张 224x224 的图,假设特征图是 28x28,那就是 784 个特征向量。如果训练集有 200 张正常图,那就是 156800 个特征向量。推理时每个测试特征都要跟这 15 万个特征算距离,计算量太大。所以 PatchCore 必须做特征降采样,这就是它第一个关键设计。
2.3 贪心核心集采样:把记忆库压缩到可接受规模
PatchCore 论文里提出的贪心核心集采样(Greedy Core-Set Sampling)是它区别于 PaDiM 等前辈的核心创新之一。这个算法的目标是从海量正常特征中选出一个子集,使得这个子集能很好地“代表”整个特征分布,同时规模可控。
具体做法是:先随机选一个特征作为初始核心集,然后迭代地找离当前核心集最远的那个特征,把它加入核心集,直到核心集达到预设大小。这个贪心策略的直觉是,每次选最远的点,能最大程度地覆盖整个特征空间的分布。论文里用的距离是欧氏距离,核心集大小通常设为总特征数的 1% 到 10%。
我实测下来的感受是,这个采样步骤对最终性能影响很大。如果核心集太小,记忆库覆盖不够,容易漏检;如果太大,推理速度下降,而且可能引入噪声。在 MVTec AD 上,论文推荐的核心集比例是 1%,但在实际项目中,我一般会调到 5% 到 10%,因为工业场景的缺陷类型更复杂,需要更密的覆盖。
一个实操细节:贪心核心集采样的计算复杂度是 O(n²),当特征数量到几十万级别时,直接算会非常慢。实际实现时一般会先用随机采样把候选集降到几千个,再在候选集上做贪心。这个近似对最终性能影响很小,但速度提升非常明显。
2.4 推理阶段的最近邻搜索与异常打分
推理阶段,PatchCore 对测试图像的每个局部特征,在记忆库里找最近邻,用这个最近邻距离作为该位置的异常分数。然后把这些位置分数上采样回原图尺寸,得到一张异常热力图。整张图的异常分数通常取热力图中的最大值,或者用 top-k 均值。
这里有个细节值得注意:PatchCore 用的是最近邻距离而不是平均距离。为什么?因为异常检测的本质是“找偏离正常分布的东西”,最近邻距离反映的是“这个特征离正常特征有多远”,而平均距离会被大量正常特征拉低,导致异常信号被淹没。用最近邻的话,只要有一个正常特征跟它接近,就说明这个位置可能是正常的;如果连最近邻都远,那大概率是异常。
另外,PatchCore 在计算异常分数时还做了一个重加权操作:用测试特征与最近邻之间的特征差异来调整分数。这个操作的目的是让异常分数更平滑,减少噪声导致的误报。具体公式这里不展开,但理解它的作用就行——让热力图看起来更干净,缺陷区域更突出。
3. 特征提取网络的选型与中间层特征融合
3.1 为什么选 WideResNet50 而不是 ResNet18
PatchCore 论文里默认用的是WideResNet50,在 ImageNet 上预训练。这个选择不是随便定的。我试过用 ResNet18 和 ResNet50 做对比,在 MVTec AD 上,WideResNet50 的 AUROC 比 ResNet18 高了大概 3 到 5 个百分点,比 ResNet50 高了 1 到 2 个百分点。
原因在于,WideResNet50 的宽度更大,特征表达能力更强,而且它在 ImageNet 上学到的特征对纹理和形状的区分度更好。工业缺陷很多时候表现为纹理异常(比如织物上的瑕疵、金属表面的划痕),需要网络对纹理有足够的敏感度。ResNet18 太浅太窄,特征判别力不够;ResNet50 虽然深,但宽度不如 WideResNet50,在某些细粒度纹理上的表现稍逊。
当然,这不是绝对的。如果你的场景对推理速度要求极高,ResNet18 也不是不能用,只是可能需要调一下核心集比例和阈值。如果追求极致性能,可以试试更大的 backbone,比如 WideResNet101 或者 EfficientNet-B5,但收益递减,而且推理速度会明显下降。
3.2 中间层特征的选择:layer2 和 layer3 的搭配
PatchCore 不是只用最后一层的特征,而是用了中间层的特征。具体来说,它从 WideResNet50 的 layer2 和 layer3 各取一部分特征,拼接起来用。为什么要用中间层?因为深层特征虽然语义信息丰富,但空间分辨率低,对小缺陷不敏感;浅层特征空间分辨率高,但语义信息弱,容易受噪声干扰。中间层是一个折中,既有一定的语义信息,又保留了足够的空间细节。
论文里的做法是:layer2 的输出特征图尺寸是原图的 1/8,layer3 是 1/16。把 layer3 的特征上采样到跟 layer2 一样的尺寸,然后拼接。这样得到的特征既有 layer2 的细节,又有 layer3 的语义。我实测下来,这个搭配在大多数工业场景下都是最优或接近最优的。如果你做的是非常细小的缺陷(比如几个像素的划痕),可以试试把 layer1 也加进来,但要注意特征维度会变大,记忆库规模也会增加。
一个容易踩的坑:不同层的特征尺度不一样,直接拼接之前一定要做归一化。我见过有人直接 concat 然后效果很差,排查半天发现是 layer2 的特征值范围跟 layer3 差了一个数量级,导致 layer3 的特征被完全压制。正确的做法是对每层特征分别做 L2 归一化,再拼接。
3.3 特征图分辨率与感受野的权衡
特征图的分辨率直接决定了 PatchCore 能检测到多小的缺陷。假设输入图像是 224x224,layer2 的特征图是 28x28,那每个特征对应原图上的 8x8 区域。如果缺陷小于 8x8 像素,可能就被平均掉了,检测不到。所以如果你的缺陷非常小,要么提高输入分辨率(比如用 512x512),要么用更浅层的特征。
但提高分辨率也有代价:特征数量按平方增长,记忆库规模变大,推理速度下降。而且分辨率太高的话,正常样本之间的类内差异也会被放大,导致误报增加。我一般会先统计一下缺陷的像素尺寸分布,然后反推需要多大的特征图分辨率,再决定输入尺寸和用哪一层的特征。
感受野是另一个需要考虑的因素。每个特征对应的感受野大小决定了它能“看到”多大的上下文。感受野太小,可能把正常纹理的局部变化误判为异常;感受野太大,小缺陷的信号会被周围正常区域稀释。WideResNet50 的 layer2 感受野大概在 30 到 50 像素之间,layer3 在 60 到 100 像素之间,这个范围对大多数工业缺陷来说是合适的。
4. 记忆库构建的工程细节与性能优化
4.1 训练集规模对记忆库质量的影响
PatchCore 虽然不需要训练,但训练集(正常样本集)的规模和质量直接影响记忆库的质量。理论上,正常样本越多,记忆库对正常分布的覆盖越全面,漏检和误报都会减少。但实际项目中,正常样本的数量往往受限于数据采集成本。
我做过一个实验:在 MVTec AD 的 carpet 类别上,分别用 50、100、150、200 张正常图构建记忆库,看 AUROC 的变化。结果是,从 50 到 100 提升很明显(大概 3 个百分点),从 100 到 150 提升变小(1 个百分点左右),从 150 到 200 基本持平。这说明记忆库的覆盖度在 100 到 150 张左右就接近饱和了。
但这个结论不能直接套用到所有场景。如果你的正常样本类内差异很大(比如不同批次、不同光照条件),那可能需要更多样本才能覆盖所有正常模式。我的建议是,先收集尽可能多的正常样本,然后做一次核心集采样,看采样后的核心集规模是否稳定。如果核心集规模随样本数增加而持续增长,说明还没饱和,需要更多样本;如果核心集规模趋于稳定,说明覆盖度够了。
4.2 核心集比例的选择:1% 还是 10%
论文里推荐的核心集比例是 1%,但这个比例是在 MVTec AD 上调出来的,不一定适合你的场景。核心集比例的选择本质上是在覆盖度和推理速度之间做权衡。比例越高,记忆库越大,覆盖越全面,但推理越慢。
我的一般做法是:先设一个较大的比例(比如 10%),跑一遍看 AUROC 和推理速度。如果 AUROC 已经满足要求,就逐步降低比例,直到 AUROC 开始明显下降,然后取下降前的那个比例。这样能在保证性能的前提下尽量压缩记忆库规模。
另外,核心集比例跟特征数量也有关系。如果你的特征数量本来就少(比如训练集很小),那 1% 可能只有几百个特征,覆盖度不够。这时候可以适当提高比例,或者直接用全部特征(相当于比例 100%),反正数量不多,推理也不会太慢。
4.3 推理加速:从暴力搜索到近似最近邻
PatchCore 推理时需要对每个测试特征在记忆库里做最近邻搜索。如果记忆库有 10 万个特征,测试图有 784 个特征,那就是 7840 万次距离计算。用暴力搜索的话,在 CPU 上大概要几百毫秒,在 GPU 上快一些,但也不是免费的。
实际部署时,一般会用近似最近邻(ANN)算法来加速,比如 Faiss 的 IVF 索引或者 HNSW 索引。这些算法通过构建索引结构,把搜索复杂度从 O(n) 降到 O(log n) 甚至 O(1)。我实测下来,用 Faiss 的 IVF 索引,在召回率损失不到 1% 的情况下,搜索速度能提升 10 倍以上。
一个容易忽略的点:Faiss 的索引构建需要调参,比如 IVF 的 nlist(聚类中心数)和 nprobe(搜索时探查的聚类数)。nlist 一般设为 sqrt(n),nprobe 设为 nlist 的 1% 到 10%。这两个参数直接影响召回率和速度,需要根据你的记忆库规模和精度要求来调。
如果对精度要求极高,不能接受任何召回率损失,那就只能用暴力搜索。这时候可以考虑用 GPU 加速,或者把记忆库做进一步压缩(比如用 PCA 降维)。PCA 降维在 PatchCore 里其实挺常用的,把 1024 维的特征降到 256 维,搜索速度能提升 4 倍,精度损失通常很小。
5. 异常分数计算与阈值设定的实操经验
5.1 从像素级热力图到图像级判定
PatchCore 的输出是一张跟原图同尺寸的异常热力图,每个像素有一个异常分数。但实际质检时,我们通常需要的是一个图像级的判定:这张图是 OK 还是 NG。所以需要把热力图聚合成一个标量分数。
最简单的做法是取热力图的最大值。这个做法对明显缺陷很敏感,但对噪声也敏感——如果热力图上有一个孤立的噪声点,最大值会被拉高,导致误报。更稳健的做法是取 top-k 均值,比如取热力图中最高的 1% 像素的平均值。这样既能捕捉到缺陷信号,又能抑制孤立噪声。
我一般会同时看最大值和 top-1% 均值,如果两者差异很大,说明热力图上有孤立的高分点,可能是噪声,需要进一步排查。如果两者接近,说明异常区域比较集中,判定比较可靠。
5.2 阈值设定的三种策略与适用场景
阈值设定是异常检测落地时最头疼的问题之一。设高了漏检,设低了误报,而且不同产线、不同批次的最优阈值可能都不一样。我总结下来有三种策略:
第一种是基于正常样本分布。用一批正常样本跑一遍,得到它们的异常分数分布,然后取某个分位数(比如 99%)作为阈值。这种策略的假设是,正常样本的分数应该集中在一个范围内,超过这个范围的就是异常。优点是简单,不需要异常样本;缺点是如果正常样本本身有较大的类内差异,阈值会被拉高,导致漏检。
第二种是基于验证集。如果有少量标注好的验证集(包含正常和异常样本),可以在验证集上画 ROC 曲线,找使 F1 或某个业务指标最优的阈值。这种策略最准确,但需要标注数据。
第三种是基于业务约束。比如产线要求漏检率低于 0.1%,那就根据这个约束反推阈值。这种策略适合对漏检和误报有明确业务要求的场景。
实际项目中,我一般会先用第一种策略得到一个初始阈值,然后在验证集上微调。如果验证集足够大,直接用第二种策略。
5.3 误报与漏检的权衡:从业务角度调阈值
阈值调优不是纯技术问题,而是业务问题。漏检和误报的代价不一样:漏检意味着缺陷品流入下游,可能导致客户投诉甚至安全事故;误报意味着良品被误判为缺陷,导致产线停机或人工复检成本增加。
我做过一个项目,产线对漏检的容忍度极低(要求零漏检),但对误报有一定容忍度(允许 5% 的误报率)。这种情况下,阈值要设得偏低,宁可误报不可漏检。具体做法是,在验证集上找使漏检率为零的最小阈值,然后看误报率是否在可接受范围内。如果误报率太高,就需要从模型层面优化(比如换 backbone、调核心集比例),而不是继续调阈值。
反过来,如果产线对误报容忍度低(比如自动化程度高,误报会导致频繁停机),那阈值要设得偏高,优先保证低误报。这时候可能需要接受一定的漏检率,或者增加人工复检环节。
6. 复现 PatchCore 时最容易踩的五个坑
6.1 特征归一化没做对导致距离计算失效
这是我最常看到的坑。PatchCore 的核心是计算特征之间的欧氏距离,如果特征没有做归一化,不同维度的尺度差异会主导距离计算,导致结果完全不可用。正确的做法是对每个特征向量做 L2 归一化,使其模长为 1。这样欧氏距离就等价于余弦距离,计算出来的距离才有意义。
我见过有人用预训练网络提取特征后直接存起来,没做归一化,结果 AUROC 只有 60% 多,排查了半天才发现是归一化的问题。加上归一化后,AUROC 直接跳到 95% 以上。所以这一步千万不能省。
6.2 核心集采样用了随机采样而不是贪心采样
贪心核心集采样是 PatchCore 的核心创新之一,但有些复现版本为了图省事,直接用随机采样代替。随机采样的问题在于,它不能保证覆盖整个特征分布,可能漏掉一些边缘区域的正常模式,导致这些区域在推理时被误判为异常。
我做过对比实验:在 MVTec AD 的 transistor 类别上,用贪心采样比随机采样的 AUROC 高了 2 到 3 个百分点。虽然差距不算巨大,但在工业场景下,2 到 3 个百分点可能就意味着每天少几十个误报。所以如果追求性能,还是老老实实实现贪心采样。
6.3 推理时忘了做特征上采样导致热力图尺寸不对
PatchCore 输出的热力图需要跟原图尺寸一致,才能做像素级的缺陷定位。但特征图的分辨率通常比原图低(比如 28x28 vs 224x224),所以需要做上采样。有些复现版本忘了这一步,或者上采样方法不对(比如用了最近邻而不是双线性插值),导致热力图跟原图对不齐,缺陷定位不准。
正确的做法是:先用双线性插值把热力图上采样到原图尺寸,然后做一次高斯平滑,让热力图看起来更自然。高斯平滑的核大小一般设为特征图分辨率的 1/4 左右,比如 28x28 的特征图用 7x7 的高斯核。
6.4 阈值直接用了论文里的默认值
论文里通常会给出一个默认阈值,但那个阈值是在特定数据集上调出来的,不一定适合你的场景。我见过有人直接拿论文的阈值去跑自己的数据,结果要么全是误报,要么全是漏检。阈值必须根据你的数据分布和业务需求来调,不能直接用默认值。
调阈值的方法前面已经说了,这里再强调一点:调阈值之前,先确保你的异常分数分布是合理的。如果正常样本和异常样本的分数分布完全重叠,那说明模型本身有问题,调阈值也救不了。这时候需要回头检查特征提取、记忆库构建、距离计算这些环节。
6.5 忽略了图像预处理对结果的影响
PatchCore 对图像预处理比较敏感。如果你的训练图和测试图在亮度、对比度、尺寸上有差异,会直接影响特征提取的质量。我一般会做以下预处理:统一缩放到固定尺寸(比如 256x256),做直方图均衡化或者自适应对比度增强,然后归一化到 ImageNet 的均值和方差。
另外,如果训练图和测试图来自不同的采集设备或不同的光照条件,最好做一次颜色校正或者域适应。我遇到过一个项目,训练图是用 A 相机拍的,测试图是用 B 相机拍的,两台相机的色彩响应不一样,导致 PatchCore 在测试集上误报率很高。后来加了一个简单的颜色校正步骤,误报率降了一半。
7. PatchCore 的边界与我的实际使用体会
PatchCore 不是万能的。它在纹理类缺陷(划痕、脏污、气泡)上表现很好,但在结构性缺陷(比如装配错误、缺失零件)上可能不如基于分类的方法。原因是结构性缺陷往往涉及多个部件的相对位置关系,而 PatchCore 只关注局部特征,对全局结构的感知能力有限。
另外,PatchCore 对训练集的“纯净度”要求比较高。如果训练集里混入了少量异常样本(这在工业场景里很常见,因为人工筛选难免有遗漏),这些异常特征会被存进记忆库,导致推理时把这些异常也当成正常,造成漏检。我一般会先用一个简单的离群点检测(比如基于特征距离的孤立森林)清洗一遍训练集,把明显异常的特征剔除掉,再构建记忆库。
我在实际使用中最大的体会是:PatchCore 的性能上限很大程度上取决于特征提取网络的质量。如果你的场景跟 ImageNet 差异很大(比如医学影像、遥感图像),直接用 ImageNet 预训练的权重可能不够,需要考虑用领域数据做一次微调,或者换一个在相关领域预训练过的 backbone。我试过在医学影像上用 ImageNet 预训练的 WideResNet50,效果比在工业图像上差不少,后来用医学图像做了一次自监督预训练,AUROC 提升了将近 10 个百分点。
最后分享一个小技巧:如果你的产线有多个相似但不完全相同的产品型号,可以考虑为每个型号单独构建一个记忆库,而不是混在一起。混在一起的话,不同型号之间的正常差异会被当成异常,导致误报。单独构建的话,每个记忆库更紧凑,检测更准。代价是推理时需要先判断型号,再选对应的记忆库,但这点开销在工业场景下完全可以接受。