PatchCore工业质检冷启动:无监督异常检测实战方案
发布时间:2026/10/19 2:27:12 锦皓数字建站

做工业视觉的朋友应该都有体会产线最不缺的是良品图片最难搞的是缺陷图片。一条节拍几十秒的产线良品率做到99%以上一天下来真正有标注价值的缺陷可能就几十张几百种缺陷类型里大半连见都没见过。于是大多数质检项目的前三个月不是在调模型而是在凑缺陷样本、催标注、跟产线要“带病工件”。所以“冷启动”这三个字一出来做质检的人眼睛是亮的。所谓冷启动就是在一开始完全没有缺陷样本、只有正常品图片的情况下先把一套能跑、能报警、能定位的视觉检测系统搭起来。PatchCore就是这条路上绕不开的一个方法——它只用正常样本训练通过“特征记忆库最近邻距离”的方式判断异常在公开的工业异常检测基准集上图像级AUROC能到99%以上、像素级AUROC在96%以上。这篇文章就把它的思路、原理和落地细节拆开讲一遍顺便把我实际部署时踩过的坑都列出来给准备做冷启动质检的朋友一个可以直接参考的路线。1. 为什么工业质检绕不开“缺陷样本稀缺”这堵墙1.1 缺陷样本为什么这么难凑先看一组很现实的数据。很多精密制造产线的直通率在95%~99%之间这意味着缺陷率可能只有千分之一到万分之一。缺陷率低本身是好事但对算法团队来说就是灾难——你就算在产线旁边挂一个摄像头拍一整天挑出来的真正缺陷可能还不够装一个batch。更麻烦的是缺陷的长尾分布。划伤、脏污、凹坑、毛刺、异色、缺料、气泡、褶皱……每种缺陷的形态、尺度、对比度都不一样。常见的缺陷可能占了80%但剩下的20%全是一年见不了几次的“冷门缺陷”。监督学习最怕这种长尾因为模型会把注意力全放在高频缺陷上冷门缺陷基本学不到。还有一个容易被忽视的问题缺陷样本有“时效性”。模具会磨损、来料批次会变、工艺参数会调这周收集的缺陷形态下个月可能就不适用了。等你凑齐几千张缺陷图、标好、训练完产线已经换了新状态。这些因素叠加在一起导致“缺陷样本驱动”的质检项目普遍陷入一个尴尬循环总在收集数据总在标注总在追产线变化的节奏。1.2 无监督异常检测换一个问法来解题既然缺陷样本这么难收集那不如换个问法不学“缺陷长什么样”而是学“正常长什么样”。只要模型充分理解了正常状态任何偏离正常状态的区域都算异常偏离程度越大越可疑。这就是无监督异常检测Unsupervised Anomaly Detection的基本逻辑。这个思路对工业场景简直量身定做。正常品图片在产线上要多少有多少不需要老师傅逐张标注不需要等缺陷出现数据收集成本几乎为零。而且做法本身也很灵活不要让模型去预测“这是什么缺陷”只让它回答“这里正不正常、有多不正常”。即使遇到从没见过的缺陷类型只要它“长得”和正常纹理不一样就会被识别出来。这个方法还有一层好处它可以给检测提供像素级定位能力。模型不只能告诉你“这一片区域有问题”还能给你一张热力图标出问题的大致范围。后端的传统视觉规则、机械臂复检、人工复核都能基于这个定位结果继续工作。1.3 PatchCore在这条路线里为什么被频繁提起在众多异常检测方案里PatchCore是近几年被工业界提得最多的一个。原因很朴素它既能做图像级判定又能做像素级定位训练阶段不需要反向传播去更新网络权重只需要提取特征、存起来、做个采样门槛极低推理阶段就是一次最近邻检索加一个距离计算逻辑透明好解释也好排查问题。对比一下其他方案就能明白它的定位。基于重建的方法自编码器、GAN类通常要训很久而且天生有“把异常也重建得很好”的毛病基于分布建模的方法高斯分布、归一化流在图像复杂度高时容易过拟合细节纹理一多就崩。PatchCore走的是另一条路直接用预训练CNN提取特征把正常样本的特征钉在记忆库里用距离度量判断新样本是否偏离。整条链路几乎没有“玄学”成分所以成了很多团队做冷启动检测的第一选择。2. 冷启动方案的设计思路用“正常字典”反推异常2.1 为什么不直接比“整张图长什么样”刚接触异常检测的人可能会问一个很自然的问题既然只看正常样本那我训练一个图像分类器或者直接把测试图和训练图做相似度对比不行吗行但效果通常很差。整图相似度是个全局指标一个小划痕占了图像的0.1%反映到全局像素差异上可能只有一点点很容易被光照、噪声淹没。而且工业品表面大量存在“相似但不相同”的情况——同样是纹理外观这一张和那一张在像素层面就是不一样的全局对比会把正常差异和真实缺陷混为一谈。所以PatchCore的思路是降维到“局部patch”层面。把图像拆成很多小区域每个区域对应一组特征向量检测时逐块比较。缺陷往往是局部事件patch级别的比较天然更适合捕捉小目标。打个比方你要检查一面白墙有没有裂缝盯整面墙不如拿手电筒一寸一寸照过去patch就是那个手电筒的光斑。2.2 用预训练CNN当“感知编码器”的逻辑PatchCore用于编码特征的主干网络是预训练CNN论文中常用Wide ResNet-50。你可能会有疑问工业品表面和ImageNet里的自然图像差那么多预训练特征能直接拿来用吗实际效果告诉你能。预训练CNN的浅层学到的是边缘、颜色、纹理基元中层学到的是局部形状和材质感这些底层视觉特征具有很强的迁移性。一个在猫狗、汽车、桌椅上学出来的网络已经具备了区分“光滑表面”和“粗糙表面”“规则纹理”和“局部破洞”的能力。工业品的表面缺陷本质上也是一种“局部视觉模式偏离”所以预训练特征在很多质检场景里直接够用。更有意思的是这种方法不需要在质检数据上做任何微调。冷启动阶段样本本来就少微调反而容易过拟合甚至把正常样本之间的合理差异也当成异常的“特征”学进去。直接用预训练特征相当于站在一个通用的视觉认知基础上做偏差判断反而更稳。2.3 特征怎么取多尺度、邻域聚合与降维PatchCore在特征提取时不是只取某一层而是选取主干网络的多个中间层典型是layer2、layer3、layer4。原因很简单不同尺度的缺陷需要不同层级的特征去感知。细小的划痕、麻点可能在浅层更明显大面积的凹坑、变形则需要更高层、更大感受野的特征才看得出来。每一层输出特征图后还会做一个3×3邻域的平均池化操作相当于把每个像素周围一个小区域的响应融合在一起这样得到的patch特征比单点特征更稳定对噪声不那么敏感。然后把三个尺度的特征拼接到一起形成每个位置的高维向量。这个向量维度很大直接存会有压力所以会做一步降维论文做法是用PCA压缩到1024维实操中随机投影也能凑合。这里有个容易踩的坑三个层的特征图分辨率不一样拼接前需要统一尺寸否则每个空间位置对不齐。通常的做法是把低分辨率特征上采样到统一尺寸或者以中间某层的尺寸为准。2.4 记忆库太大怎么办Coreset采样把所有正常样本的patch特征全部存起来一个中等规模项目可能有几十万甚至上百万个特征向量。推理时每个patch都要在这个大库里找最近邻存储开销和计算开销都很大。PatchCore的解法是Coreset采样用最远点采样Farthest Point Sampling从全量特征中挑出一小部分作为代表在保覆盖率的同时把记忆库缩到1%左右。最远点采样的思路是先随机选第一个点然后反复找“离已选集合最远”的样本加进去。这样选出来的子集能在特征空间里铺得比较开不会像纯随机采样那样漏掉一些分布比较稀疏的小簇。用一个生活里的事来解释如果要在小区里设几个快递驿站最稳妥的做法不是随机选几栋楼而是让驿站尽量分散保证每个住户走到最近驿站的距离都不太远。Coreset就是在特征空间里干这件事。实验下来1%的coreset就能逼近全量记忆库的检测效果而检索效率能提升两个数量级。2.5 分数设计k近邻距离与异常概率的转换特征库建好之后检测阶段就变成了一个检索问题。对测试图像的每一个patch特征在记忆库中找它的k个最近邻计算这几个近邻的平均欧氏距离。正常patch在库里有大量相似的“邻居”距离小异常patch找不到像样的邻居距离大。PatchCore将这个距离转换为0到1的分数score 1 - exp(-average_distance)。距离趋近0时分数趋近0距离越大分数越接近1这个非线性映射让分数分布不那么敏感也方便设定统一阈值。默认k取9这个值既能提供一定的鲁棒性又不会把真正的异常patch“平均”掉。图像级分数取当前图像所有patch分数的最大值。这样做是有讲究的一个很小的缺陷可能只影响几个patch如果用平均值缺陷信号会被大多数正常patch的分数淹没用最大值再小的局部异常也能被捕捉到。像素级分割图则是把每个patch的分数按空间位置排列成一张低分辨率热力图然后双线性插值到原图尺寸再做一个高斯平滑。2.6 冷启动到底需要多少正常样本这个问题几乎没有标准答案但根据我的实操经验可以给出一个参考范围每类产品/每个工位准备500~2000张正常品图像比较稳妥覆盖不同光照、角度、来料批次如果只有一两百张也能跑起来但阈值必须设得保守一点宁肯误报多一点也不能漏检。数据增强在冷启动阶段要谨慎。翻转、旋转、轻微的亮度/对比度扰动可以做因为它们在真实产线中确实会出现但像随机擦除、颜色反转这类“故意制造异常”的增强不要用会让正常样本的分布被人为撑大导致真正的小缺陷被判定为正常。3. 从零搭建PatchCore冷启动质检流程3.1 数据准备与目录规划先说数据组织。一个典型的冷启动项目初始阶段只会有一个“正常品”目录。我习惯这样建目录data/ ├── train/ │ └── good/ # 只有正常图像 │ ├── 001.png │ ├── 002.png │ └── ... ├── val/ │ └── good/ # 正常验证集用来定阈值 │ ├── 001.png │ └── ... └── test/ ├── good/ # 正常测试图 └── ng/ # 后续收集的缺陷图先不急着有训练目录只放正常图验证目录也必须是独立的正常图千万不能和训练目录共用素材否则阈值会严重乐观估计一上线误报率直接起飞。我见过不止一个团队把同一批正常图既建库又定阈值当场跑分漂亮得不行进产线第一天就报警报成筛子。图像读取统一用OpenCV或PIL处理成RGB保存时记录原始尺寸因为推理阶段需要知道原图大小来还原热力图。3.2 环境与依赖实验环境一般是Python 3.8及以上PyTorch 1.10以上Torchvision带预训练权重再加sklearn和faiss。如果显卡显存有限faiss可以换成CPU版本后面会讲怎么在CPU上扛住。pip install torch torchvision scikit-learn faiss-cpu opencv-python pillow显存方面我实际测下来16G显存的显卡跑Wide ResNet-50特征提取非常轻松8G也够用因为推理阶段只会过一遍网络不像训练那样需要保存中间梯度。3.3 特征提取与记忆库构建核心逻辑分四步过网络、取多层特征、邻域聚合、降维存库。import torch import torch.nn.functional as F from torchvision import models # 加载预训练的 Wide ResNet-50只做前向 backbone models.wide_resnet50_2(weightsmodels.Wide_ResNet50_2_Weights.IMAGENET1K_V1) backbone.eval().cuda() def extract_layer_features(x): feats [] x backbone.conv1(x) x backbone.bn1(x) x backbone.relu(x) x backbone.maxpool(x) x backbone.layer1(x) x backbone.layer2(x) feats.append(F.avg_pool2d(x, kernel_size3, stride1, padding1)) x backbone.layer3(x) feats.append(F.avg_pool2d(x, kernel_size3, stride1, padding1)) x backbone.layer4(x) feats.append(F.avg_pool2d(x, kernel_size3, stride1, padding1)) return feats三个层输出的尺寸不同需要统一。我一般以layer3的特征图尺寸为基准把layer2和layer4的特征分别池化或插值到同样大小# feat2, feat3, feat4 是三个层经过 avg_pool 后得到的特征 h, w feat3.shape[-2:] feat2 F.interpolate(feat2, size(h, w), modebilinear, align_cornersFalse) feat4 F.interpolate(feat4, size(h, w), modebilinear, align_cornersFalse) # 通道维度拼接得到 (B, C, H, W) feat_concat torch.cat([feat2, feat3, feat4], dim1) feat_flat feat_concat.permute(0, 2, 3, 1).reshape(-1, feat_concat.size(1))拼接后的维度比较大用PCA压到1024维。需要注意PCA必须在训练集所有patch特征上拟合再transform不能拿一小部分特征拟合否则投影方向会有偏差。from sklearn.decomposition import PCA pca PCA(n_components1024, whitenTrue) all_feats collect_all_patch_features(train_loader) # 全量提取后拼接成一个 (N, C) 大矩阵 all_feats pca.fit_transform(all_feats)然后做Coreset采样。最远点采样直接用torch.cdist会爆内存尤其是N达到几十万时。我的做法是分块计算距离矩阵一遍一遍选import torch def farthest_point_sampling(feats, ratio0.01): n feats.shape[0] m max(1, int(n * ratio)) feats_t torch.tensor(feats).float().cuda() selected [0] cand_mask torch.ones(n, dtypetorch.bool, devicecuda) # 初始化距离到第一个点的距离 dists torch.norm(feats_t - feats_t[0], dim1) for _ in range(m - 1): cand_mask[selected[-1]] False # 找离已选集合最远的候选点 far_idx torch.argmax(dists[cand_mask]) far_idx torch.nonzero(cand_mask)[far_idx].item() selected.append(far_idx) # 更新每个候选点到最近已选点的距离 cur_dist torch.norm(feats_t - feats_t[far_idx], dim1) dists torch.minimum(dists, cur_dist) return feats_t[selected].cpu().numpy()这个写法对几十万特征、1%采样比例是能跑完的就是循环有点慢。真要提速可以先把全量特征随机抽到10万左右再做最远点采样效果几乎一样。3.4 推理打分与阈值设定推理阶段的流程和训练完全相同提取测试图的patch特征、PCA投影、然后在coreset库里找k个最近邻。from scipy.spatial.distance import cdist def compute_scores(test_feat_flat, coreset_feats, k9): # test_feat_flat: (M, D) 一个测试图的所有patch特征 # coreset_feats: (Nc, D) 采样后的记忆库 dist_mat cdist(test_feat_flat, coreset_feats, metriceuclidean) knn_dists np.partition(dist_mat, kthk, axis1)[:, :k] # 取每行前k小 mean_dists knn_dists.mean(axis1) scores 1 - np.exp(-mean_dists) return scores阈值怎么定很关键。用独立验证集中的正常图跑一遍得到所有patch分数的分布取99分位数或者99.5分位数作为图像级异常阈值。以后每张测试图只要图像级分数超过这个阈值就判NG。如果你手里恰好有一小批已经确认过的缺陷样本也可以用ROC曲线选约登指数但要注意缺陷样本的分布能不能代表未来真实出现的缺陷不能就还是用分位数更稳。分割图直接由每个patch的分数构成低分辨率图然后上采样回原图尺寸再加高斯滤波score_map scores.reshape(1, 1, h, w).astype(np.float32) score_map cv2.resize(score_map, (orig_w, orig_h), interpolationcv2.INTER_LINEAR) score_map cv2.GaussianBlur(score_map, (5, 5), sigmaX1.0)后续要不要做连通域过滤取决于产线需求。我通常会过滤掉面积小于某个阈值的小噪点区域减少误报但如果缺陷本身特别小这个步骤就需要谨慎可能直接把真缺陷过滤掉了。3.5 工程化落地时的几个必看细节推理性能是落地时最先遇到的问题。一个直接的优化是把coreset库塞进faiss索引用IndexFlatIP或IndexIVF做检索检索速度能比暴力cdist快很多。在几千个coreset样本上几千张测试图的patch检索单卡GPU或CPU都能做到单图几十毫秒级别。显存不够时可以考虑把特征提取和检索拆开或者干脆分批提取patch特征。还有一个实用技巧建库时先随机抽一部分特征做PCA拟合再用全量特征transform这样既保证投影方向稳定又不至于让PCA拟合卡死。对于产线有多个型号/多工位的情况不要把所有正常数据混在一个库里。型号A的表面纹理和型号B差很多混在一起会互相污染。更合理的做法是给每个型号建独立的记忆库和阈值推入系统时指定型号ID自动加载对应的库文件。4. 部署中真正会遇到的坑与排查方法4.1 图像级分数正常却漏掉明显缺陷冷启动阶段最容易出现的问题就是“看着像是缺陷但分数不高”。我排查下来最常见的原因是coreset比例被压得太低。1%听起来合理但如果你正常样本特别多、特征分布又很密集1%可能采不到足够有代表性的边界样本导致正常patch和异常patch的分数分不开。遇到这种情况先把coreset比例提到2%~3%再跑一遍对比。第二个原因是PCA把判别信息丢了。whitenTrue会对特征做白化一些方向上的微弱差异会被压缩掉。如果问题集中在小对比度缺陷可以关掉whiten或者降低PCA维数重新试。第三个原因就比较常见了——光照条件。训练库里的正常图都在A光源下拍测试现场换成了B光源整个表面纹理的统计特性都偏移了所有patch距离都会变大但都是整体偏移缺陷和正常区域之间的距离差反而被掩盖了。这种情况先做图像级的光照归一化直方图匹配、灰度校正比调整算法参数有用得多。4.2 分割图太糊定位不准热力图不准会让后端机械臂复检无从下手。我遇到过的问题里有三个高频原因只用了单一高层特征、没有做高斯平滑、或者上采样方式不对。只取layer4特征的感受野太大分割结果往往是一大片弥漫的响应根本看不出缺陷边缘。解决方案是像PatchCore论文一样把layer2/layer3/layer4都融合进来保留更多细节。上采样推荐双线性插值用最近邻会出现明显的方块感。高斯平滑可以让热力图更连贯但如果发现小缺陷被磨没了把核从5×5缩到3×3或者干脆去掉平滑。还有一个非常实用的小技巧如果你手头有缺陷图的标注框哪怕只是粗略的bounding box可以把真实缺陷区域对应的patch分数单独统计一下用来校准可视化时的颜色映射范围。这是当前标定出来的分界线一旦确认合理后面的调试会高效很多。4.3 正常样本只有几十张能跑吗能跑但你要接受误报高的事实。几十张正常图意味着记忆库只能覆盖很少的纹理变化任何没见过的光照、角度、来料波动都会被当成异常。这时候最值得做的事情不是换算法而是扩大正常样本的覆盖度。没有现成正常品时可以用不同光源角度、不同曝光拍同一批正常品这样相当于在库中人为制造合理的“正常变体”。如果现场连拍都困难那就必须降低阈值校准的分位数预期。比如正常验证集99分位数在实际使用中稳不住可以把阈值提到99.9分位数先保证不漏检误报交给后端的复检逻辑消解。毕竟冷启动阶段“漏了”比“误报”严重得多。4.4 指标虚高数据泄露是最隐蔽的坑实验室里跑得特别好产线上一塌糊涂十有八九是数据泄露。一个典型场景同一张正常图被翻转/裁剪出多个版本一部分放进训练库一部分放进验证集。表面上看两个集合不重叠实际上内容一模一样验证分数虚高得离谱。正确的做法是按“原始拍摄对象”为准进行数据划分。同一张原始图像不管怎么增强只能归入一个集合。最好直接把现场采集时间作为划分依据前三天拍的当训练库第四天拍的当验证集第五天拍的当测试集。这样验证结果才真正反映了“没见过的新样本”会如何表现。4.5 换线、换型号和长期漂移问题即便冷启动成功也不能一劳永逸。模具磨损、原材料批次更替、季节温湿度变化都会让正常状态慢慢偏移。我维护线上系统时每个班次都在记录图像级分数的分布一旦发现整体分数均值漂移超过一定范围就触发“记忆库刷新”流程从当前产线上重新采集一批确认无缺陷的图像增量式更新记忆库。增量更新的方式是只对新增正常样本提取特征、降维、追加到coreset候选池重新做最远点采样。整个过程不需要重新跑PCA也不影响其他型号的记忆库一分钟内就能完成。定期做这个操作系统就能跟着产线“慢慢适应”又不至于把新出现的缺陷也学成正常。4.6 问题速查表现象常见原因处理建议图像级分数普遍偏高光照变化、图像预处理不一致统一光照归一化流程重新校准阈值漏检明显缺陷阈值过高、coreset比例过低降低阈值分位点增大coreset比例分割热力图模糊只用了高层特征、缺少平滑多尺度特征融合合理上采样高斯平滑验证集指标好线上误报高数据泄露、验证集与训练集混用按采集时间/原始对象重新划分数据单图推理太慢暴力cdist检索改用faiss索引或缩小coreset比例显存不足全量特征一次性加载分批提取特征CPU fallback新型号上线误报多跨型号共用了同一个库按型号独立建库、独立设阈值小缺陷分数被平均值稀释图像级分数用了均值改回最大值聚合或增加patch权重我个人在实际操作中的体会是PatchCore从来不是“跑通就完事”的工具它真正的作用是在冷启动阶段给你一个稳定、可解释、能快速迭代的基线。很多项目最后并不是输在算法上而是输在数据划分不严谨、阈值校准不科学、现场光照没控制好这些细节上。先把这条基线做扎实再去考虑蒸馏一个轻量模型部署到边缘设备或者用它在线标注一批高置信度缺陷样本给后续的监督模型当训练集都是很顺的路。最后留一个建议正式上线前至少准备一周的现场正常数据做阈值稳定性观察分数分布一天比一天稳定了再逐步放开自动分判。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。