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 而不是 ResNet18PatchCore 论文里默认用的是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/8layer3 是 1/16。把 layer3 的特征上采样到跟 layer2 一样的尺寸然后拼接。这样得到的特征既有 layer2 的细节又有 layer3 的语义。我实测下来这个搭配在大多数工业场景下都是最优或接近最优的。如果你做的是非常细小的缺陷比如几个像素的划痕可以试试把 layer1 也加进来但要注意特征维度会变大记忆库规模也会增加。一个容易踩的坑不同层的特征尺度不一样直接拼接之前一定要做归一化。我见过有人直接 concat 然后效果很差排查半天发现是 layer2 的特征值范围跟 layer3 差了一个数量级导致 layer3 的特征被完全压制。正确的做法是对每层特征分别做 L2 归一化再拼接。3.3 特征图分辨率与感受野的权衡特征图的分辨率直接决定了 PatchCore 能检测到多小的缺陷。假设输入图像是 224x224layer2 的特征图是 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 个百分点。最后分享一个小技巧如果你的产线有多个相似但不完全相同的产品型号可以考虑为每个型号单独构建一个记忆库而不是混在一起。混在一起的话不同型号之间的正常差异会被当成异常导致误报。单独构建的话每个记忆库更紧凑检测更准。代价是推理时需要先判断型号再选对应的记忆库但这点开销在工业场景下完全可以接受。