工业异常检测完全召回:预训练特征与最近邻方案详解
在产线上调过检测算法的人都体会过“漏检”这两个字的重量。模型精度报表很漂亮可某个批次里偏偏漏掉一只边缘开裂的工件后面装配线整段报废返工代价远超想象。工业异常检测里最核心的那个目标说白了就是“完全召回”宁可多报警也绝不让异常悄悄溜走。这个研究方向有一个很直白的名字Towards Total Recall in Industrial Anomaly Detection译过来就是“迈向工业异常检测中的完全召回”。它要解决的问题非常具体——在只知道正常样本长什么样的情况下如何尽量把每一个异常都找出来。这篇文章会把这套思路背后的原理、评估细节和落地时容易踩的坑一次性讲透适合正在做工业质检算法或者刚接触异常检测的朋友参考。1. 为什么“完全召回”像一道不可能三角1.1 漏检的真正成本在聊算法之前先说说漏检为什么不能用普通指标衡量。工业质检里漏检意味着缺陷流入下一道工序轻则影响装配精度重则整批返工甚至流出到客户端演变成索赔。现场工程师有句话很直白“一个漏检造成的损失够调十版模型。”很多刚上手的人会犯同一个错误只看准确率。模型准确率报99.8%看起来很美但产线一旦跑起来某类没见过的缺陷会以极高的概率被放过因为模型根本没学过它。工业项目的验收标准里召回率往往排在准确率前面原因就在这里。所谓“完全召回”本质上是把一个安全底线焊接进系统的目标异常识别宁可错杀不能放过。1.2 异常检测的基本设定只有正常样本可用工业异常检测真正的难点是训练数据的天然不对称。一条产线能稳定提供大量正常工件但异常样本要么采集不到要么种类多到数不清。划痕、凹坑、脏污、毛刺、边缘缺损、纹理不均每一类都可能单独出现想靠人工标注把负类穷举出来成本完全不现实。所以这个任务的默认设定是“单类学习”只用正常样本来建模异常样本只出现在推理阶段。这个设定决定了“完全召回”不可能靠常规分类实现。你不能问模型“这个是哪种缺陷”只能问它“这个还像不像正常”。模型对“正常”的边界画得越紧、越准异常被漏掉的可能性就越低边界一旦松了召回率立刻崩。追完全召回的第一步其实就是把正常分布的边界理解到位。1.3 阈值那一刀任何检测模型输出的都不是标签而是一个分数。最后“报不报警”靠的是一个阈值。阈值定低报警多召回高但误报也多定高误报少但细小的缺陷也容易被放过。这里就出现了一个不可能三角完全召回、低误报率、高吞吐在单阈值下最多只能满足两个。想打破这个三角靠调阈值是没有出路的真正要做的只有一件事拉大正常样本和异常样本之间的分数间隔。间隔越大阈值的选择余地越大才有可能同时做到高召回和低误报。这也是为什么方法选型比调参重要得多。2. 不同技术路线在“召回”上的实际差距2.1 传统模板匹配对得上就稳对不上就漏早期工业界用得最多的方案是模板匹配加图像差分先拍一张标准件作为模板然后逐像素做差超过阈值的区域就算异常。这套逻辑对固定位置、固定光照、规则纹理的场景很有效但它的边界画得非常死。光照稍微波动差分图噪声就变大噪声变大后为了压误报只能把阈值调高一调高细微缺陷就又漏了。更麻烦的是换一个工件型号整条模板流程就要重新标定。模板匹配在场景可控时确实能守住召回但一旦场景变得“不那么完美”召回就全靠运气。这个路线的根本问题是把“异常检测”降级成了“图像配准”漏检风险被转嫁给了环境稳定性。2.2 重建式方法重建误差的运气成分后来很多人转向重建式方法核心思路是用自编码器或者生成模型压缩正常样本再把它还原出来认为正常样本重建误差小、异常样本重建误差大用误差大小来判定异常。听起来合理实际效果却很看运气。工业纹理的细节信息在压缩和重建过程中很容易丢失重建模型对细小纹理变化并不敏感更让人头疼的是网络见过的常见瑕疵模式会被它“记住”下次真出现时反而重建得很好。也就是说重建式方法对“训练中出现过的异常”有记忆效应对“新出现的异常”却常常失效。Demo阶段看着指标不错上线跑一周各种漏检就冒出来了。它在特征空间里缺少一个清晰稳定的“正常边界”误差值受重建容量影响太大距离完全召回还有很大差距。2.3 预训练特征加最近邻为什么这条路更接近完全召回把思路从“生成像素”切换成“判别特征”之后情况开始好转。基于预训练特征与最近邻的路线做法大致是用预训练卷积网络把正常样本切成局部块patch并提取特征全部存进一个记忆库推理时看当前patch的特征在记忆库里最近邻的距离有多远。距离近说明它像正常距离远说明它可能是缺陷。这套方案的优势来自两个关键点。第一预训练模型是在海量自然图像上训练的得到的特征表达对细小纹理、局部语义变化都很敏感第二特征是局部化的不是整图一个向量细小缺陷不会在“平均”中被稀释。很多公开基准实验里这条路线在图像级AUC和区域级定位上都明显优于重建式方法尤其在纹理类缺陷上优势更明显。可以说它是在工程可接受的成本下最接近“完全召回”目标的一种框架。技术路线对光照变化的鲁棒性对未知缺陷的召回工程落地成本主要局限模板匹配差分差不稳定低依赖严格配准扩展性差重建式自编码中容易失效中对纹理细节不敏感有记忆效应预训练特征kNN较好稳定偏高中高记忆库占用大需优化3. 记忆库加最近邻如何一步步逼近完全召回3.1 特征提取把像素变成能比对的语义描述这套框架的第一步是特征提取。具体做法是将图像归一化后送入一个预训练网络取多个阶段的输出作为特征。为什么非要用预训练网络而不是自己从头训练一个因为工业场景里正常样本数量有限自己训练出的特征可能只对当前批次的表面特征敏感一旦光照、材质微变特征分布就漂移而预训练模型见过足够多的自然图像学到的是相对稳定的底层视觉结构泛化能力强得多。实际使用中需要注意一个隐性陷阱预训练模型对输入归一化方式有要求如果使用的均值方差和训练时不一致特征分布会发生偏移后面所有距离计算都会受影响。另一个细节是特征层级选择。浅层特征保留纹理、边缘深层特征偏向语义结构工业缺陷往往既包含细小纹理变化又包含局部结构异常所以通常取中间偏后的几个层级必要时拼接起来用。3.2 局部特征与位置信息的保留为了定位异常出现在图像的哪个位置特征不能做成整图一个向量。常见的做法是把特征图划分成patch每个patch对应原图上的一个区域这样一个特征向量携带了空间位置信息。推理时逐patch与记忆库比较得到一张逐位置异常热度图再上采样还原回输入分辨率。这个“局部化”细节对完全召回极其重要。想象一个只占图像千分之一像素的针孔缺陷如果压缩成整图向量它会被周围正常区域“平均”掉距离分数几乎无变化。但把它拆成patch后缺陷所在patch的特征会明显偏离正常邻居距离分数被拉高缺陷就暴露了。所以patch尺寸本身就是一个超参数要与产品上出现的最小缺陷尺寸匹配。3.3 记忆库压缩贪婪子采样如何留住边界信息把所有正常样本的所有patch特征全存下来在工程上是不现实的。以一万张正常图为例每张图生成数千个patch特征累计就是上千万个向量查询速度和内存都扛不住。因此必须做压缩即从大规模特征集合中挑选一个有代表性的子集。常见做法是贪心最远点采样。初始化任选一个特征加入集合然后反复挑选距离当前集合最远的特征加入直到达到预定数量。它的数学直觉很清晰正常样本在特征空间里分布成一个流形我们真正关心的不是流形内部那些密集雷同的点而是流形边界上定义范围的信息。边界上的特征一旦缺失正常分布的轮廓就“凹进去”一块异常样本就可能从这个缺口溜走。贪心最远点采样本质上是在尽量压缩冗余的同时保住正常流形的边界。3.4 推理阶段距离分数与多尺度融合推理时的核心计算是每个patch特征与记忆库中k个最近邻居的检索。这里有一个值得注意的细节用k个邻居的平均距离而不是只用最近邻距离。k近邻平均能在一定程度上抵抗记忆库中个别噪点特征的影响让分数更稳定。k值通常取2到5太小不稳定太大又会让异常特征被正常邻居“淹没”。多尺度融合也是提召回的手段。不同尺寸的缺陷对patch大小敏感小patch抓细节大patch抓结构。推理时可以把同一张图缩放到多个尺度分别提取特征再把逐位置分数对齐融合最终热力图取加权和。这样同一处缺陷只要在任意尺度下分数突出就不会漏掉。下面是一个推理阶段的核心流程示意import torch def anomaly_score(patch_feats, memory_bank, k3): # patch_feats: [N, D], memory_bank: [M, D] dists torch.cdist(patch_feats, memory_bank) # [N, M] knn_dists, _ torch.topk(dists, k, largestFalse) # 取k个最小距离 score knn_dists.mean(dim-1) # [N] return score到这里整套方法的主干就清晰了提取局部特征、构建压缩记忆库、用k近邻平均距离给出异常分数。后面所有工程细节本质上都是在优化这三个环节。4. 评估指标的深层含义完全召回不代表躺赢4.1 图像级指标与区域级指标的差别完全召回这个目标在评估层面也暗藏玄机。常见的AUC指标其实分两类图像级和区域级。图像级AUC衡量的是“一张图有没有缺陷”的排序能力更贴近产线报警区域级指标衡量的是“缺陷区域是否被准确定位”关系到后续能不能自动裁剪或修复。很多模型在图像级AUC上表现很好但区域级一塌糊涂说明它只是“整张图亮了”并不知道缺陷具体在哪。工业现场如果只需要报警停机图像级够用但如果要触发机械臂打磨或分拣区域级指标就同样关键。做完全召回验证时两类指标都要看只看一个容易被表面的高分成绩骗过去。4.2 高召回与低误报如何共存排序指标和实际阈值的错位AUC只衡量排序能力不关心具体阈值所以AUC高并不代表部署时你能轻松卡出一个低误报的高召回阈值。真正部署时你需要一个固定阈值。如果阈值标定不好AUC再高线上照样漏。标定阈值时最容易犯的错是直接在验证集上选“召回率最大”的点。工业验证集的异常样本往往很少可能就几十张标准无缺陷样本却有几千张单纯追求最大召回会把阈值压得过低误报爆炸。更稳的做法是先设定业务能接受的每批次最大误报数再据此在正常验证集上选阈值最后在异常集上验证召回率是否达标。4.3 定位粒度的后处理能检出还得能剪得出来区域级召回还牵涉到一个容易忽视的问题模型输出的热力图往往边缘模糊直接按阈值切mask缺陷区域会带着一圈毛刺或者连成一大片。想让区域级指标好看必须做后处理。我常用的方案是先对热力图做一次高斯模糊降低孤立噪点的影响然后按分位数取mask最后用连通域分析去掉面积过小的碎片保留主要缺陷区域。这一步看着小却能明显提升区域级召回指标的稳定性尤其会让缺陷边界干净许多。可以说完全召回在工程上不只是“检测到”还包括“定位得准、切得出来”。5. 落地实践中的关键细节与踩坑记录5.1 图像尺寸、patch大小和缺陷尺度的匹配工业相机拍出来的图往往非常大动辄上千万像素预训练模型通常只能吃224或256尺寸的输入。直接把大图resize到224细小的缺陷会缩小到几个像素特征完全消失不resize网络结构又不支持。工程上最常见的折中是先下采样到1024或512再滑窗切块分批送入网络提取特征。这里最关键的坑是patch大小必须和缺陷尺寸匹配。我见过有人固定用16x16的patch检测大面积缺料特征碎片化严重整块区域全是“异常边缘”定位结果乱七八糟。反过来小针孔缺陷又不能用64x64的patch缺陷只占patch的一小块距离分数会被正常像素拉平。实操时最好把最小关注缺陷尺寸换算成原图像素再反推patch大小。这个匹配关系一旦错了后面调什么参数都白搭。5.2 记忆库大小与显存、耗时的权衡记忆库是这套方案最主要的资源消耗点。理论上采样率越高正常边界保留得越完整召回越稳但采样率上去查询耗时和显存占用同步上涨。我做过一组简单对比以常用公开基准数据为例采样率从0.1降到0.01图像级AUC通常只掉零点几个百分点但推理耗时可以快上接近一个数量级。所以不要盲目追求“整个记忆库全存上”。实际操作中可以用“边采样边验证AUC”的策略逐步下调采样率直到AUC开始出现肉眼可见的下跌再回退一档。另一个工程技巧是把记忆库特征从float32转成float16显存直接减半精度损失几乎可以忽略。查询部分如果patch数量太大还可以引入近似最近邻索引牺牲极小的精度换大幅提速。5.3 阈值标定的实操方案我自己的标定流程比较保守。先在正常验证集上算出所有patch的距离分数取第99.5百分位作为初始阈值再放到异常验证集上看能召回多少。如果业务要求更高就把百分位压到99.9。这个百分位本质上是在回答一个问题你最多愿意让百分之多少的正常patch被误报。import numpy as np normal_scores get_patch_scores(validation_normal) # 正常样本全部patch的异常分数 threshold np.percentile(normal_scores, 99.5) # 默认99.5分位可按业务调整 print(f初始阈值: {threshold:.4f}) abnormal_scores get_patch_scores(validation_abnormal) recall (abnormal_scores threshold).mean() print(f对应召回率: {recall:.4f})这里有一个隐含条件正常验证集必须足够“纯”。如果验证集里混入了少量缺陷样本阈值的百分位会被这些异常分数抬高线上正常样本会大面积误报。所以标定前要花时间人工确认验证集干净度这一步不能省。5.4 纹理缺陷与结构缺陷的差异表现不同材质上的缺陷在这套框架下的表现差异很大。结构类缺陷比如划痕、凹坑、缺料特征是“局部突变”patch特征离正常簇很远距离分数一下子拉高召回相对容易。纹理类缺陷则麻烦得多比如布料纹路异常、皮革毛孔粗细不均正常样本本身的特征分布就非常散异常特征和正常特征的边界模糊距离分数即使偏大也容易被淹没。针对纹理类材质有一个实操经验适当调大patch尺寸让每个patch容纳更多纹理上下文比单纯增加记忆库数量更有效。因为纹理异常往往是“局部统计特征变了”而过小的patch只能看到几根纤维无法感知纹理规律。另一方面对纹理类产品要谨慎做旋转增强纺织品的经纬方向是有物理意义的乱旋转反而会把正常特征空间“撑”得过大降低召回。6. 进一步提高召回率的库外经验6.1 多层级特征的组合拼接只取单层特征做距离计算召回率往往有上限。浅层特征虽然细节丰富但受光照噪声影响大深层特征抗干扰能力强却可能忽略微小瑕疵。实践中常用多层级拼接取骨干网络倒数第二层和第三层的输出先各自做归一化再拼接成一个更长的特征向量必要时用PCA或白化降维再入库。我自己的测试里单层特征在一个纹理类场景的图像级AUC大约在92左右拼接后可以提到95以上而区域级提升更明显。代价是特征维度翻倍记忆库和查询耗时随之上升。这里需要做一次成本收益评估但如果你的核心诉求就是“不放过任何一个异常”多层级拼接很值得先试一版。6.2 数据增强对记忆库质量的影响记忆库里正常样本的多样性直接决定正常分布边界的“体积”。如果只有一两个姿态和光照条件下采集的正常样本正常流形覆盖不足现场稍微有一点角度偏移就会被判断为异常。反过来说边界volume太小的后果不只是误报还可能让真实异常被挤进“正常范围”里漏掉。处理办法是给正常样本做适度增强小角度旋转、平移、亮度饱和扰动、轻微模糊。这样相当于人为把正常流形撑大让边界更贴合真实生产环境。但增强幅度要克制尤其是纹理和方向性明显的工件过大的旋转会引入在现实中不可能出现的“伪正常形态”反而稀释了异常特征的区分度。增强幅度通常以生产线上能观测到的正常波动范围为上限。6.3 与人工复检流程配合让模型回归“绝不漏判”的本职把完全召回当作单一模型的目标本质上是在给模型“超纲”。更稳妥的落地方式是系统设计模型负责高召回初筛把“疑似异常”的图片或工件标记出来再由人工做二次确认。这样一来模型的任务从“精确分类”变成“绝不漏判”使命和边界都清晰了召回压力也小得多。产线上跑下来这种“机器初筛加人工复检”的组合往往比换一个更贵的模型有效。因为人工复检员只需要看一小部分被模型筛出来的对象工作量可控而机器人不会疲劳、不会漏掉低对比度缺陷。完全召回在系统层面才真正变得可实现。最后分享一点我自己的体会。跑了多轮类似方案之后我最大的感受是完全召回不是一个静态指标而是整个系统的设计原则。从特征层级、patch尺寸、记忆库采样率到阈值标定和人工复检流程每个环节都在为“不放过任何异常”做让步。刚接触这个方向时我也总想着找到一劳永逸的模型后来发现真正稳的做法是让每个环节都清楚自己的边界再把它们串成一条封闭的回流链路。希望这篇梳理能让你少走一些我走过的弯路。