做工业质检的朋友应该都有过这种体验:产线那边临时要加外观检测环节,你过去一看,能拿到的缺陷样本就几十张,还全是一种类型的。拿这点数据去训分类网络,别说遇到新缺陷,连同一批次的正常波动都扛不住。我踩了大半年坑后,转向了一条完全不同的路线——零样本缺陷检测,核心思路不是教模型认识每种缺陷,而是让它深刻理解“正常”长什么样。最近我把DINOv3的特征提取针对异常检测场景做了适配,在MVTec AD标准数据集上完整验证了一遍,效果相当能打。这篇文章就把这套方案的原理、代码和坑完整记录下来,适合工业视觉从业者和刚接触异常检测的朋友。
1. 传统质检到底卡在哪?零样本思路为什么能破局
1.1 真实产线三大困境:数据少、形态多、标注贵
在工业外观检测里,大家第一反应都是走经典监督路线:拍图、标注、训练分类或者目标检测模型。这套思路在实验室里跑通很容易,真正丢到产线上就会撞上三堵墙。
第一堵墙是缺陷数据极度稀缺。工厂里的正常品每天能堆成山,缺陷品却往往只有几十张,而且这些缺陷还集中来自同一批工艺错误。你要是拿几十张缺陷图去微调一个深度模型,最好的结局就是过拟合到这几张图上,换一条产线立刻失效。这个问题的本质在于,工业过程本身追求的就是“少出缺陷”,缺陷样本少不是运气不好,而是产线运转正常的直接结果。
第二堵墙是缺陷形态永远在变。划痕、凹陷、脏污、断裂、色差、变形,每一种缺陷内部还有轻重缓急的区分。更麻烦的是,你不可能预先知道明天会出什么新的不良模式。监督方案本质上是在列举缺陷,而真实缺陷是开放集合,列举不完。我在项目的早期阶段就吃过这个亏,花了两周时间把常见缺陷类型都标了一遍,结果第三周产线上出现了之前从没见过的水渍型缺陷,模型直接漏检。从那之后我就明白,跟缺陷做枚举对抗是个无底洞。
第三堵墙是标注成本。要训练一个能用的分割模型,像素级标注一张缺陷图少说也要好几分钟。一个类别几百张标注图,累计就是几百小时的工作量,这还不算人工复核和标准统一带来的额外沟通成本。对工厂来说,质检方案如果不能快速部署、快速换线,那它在成本账上就很难看。
这三堵墙决定了传统监督方案在很多中小批量、多品种的生产线上根本算不过账。但反过来看,正常样本是永远不缺的,而且“正常”的定义相对稳固。那我们为什么不换个角度,把模型的注意力全部放在认识正常上?
1.2 不认缺陷,只认正常:零样本检测的底层逻辑
零样本缺陷检测的思路其实非常朴素:给模型一批纯正常样本,让它记住这些正常样本的特征分布;来了新图片,只要看它和正常特征库差了多少,差得多的区域就是缺陷。整个过程不需要一张缺陷样本参与训练,所以天然不受缺陷形态限制。
这里就引出了整个方案能不能成立的关键——特征表示。你得有一个足够强的特征提取器,能在一张正常图上提取出密集的特征,这些特征既要能表达局部纹理信息,又要能区分细微的异常变化。早期有人用ImageNet预训练的ResNet做这件事,效果还行,但特征是从分类任务训练出来的,更关注“物体是什么”而不是“这个区域是不是正常”,所以在细粒度缺陷上经常漏检。后来PatchCore等方法用多尺度特征缓解了一部分问题,但特征的判别力本质上仍然被预训练任务限制着。
直到DINO系列出来之后,情况才真正发生变化。DINOv2证明了自监督学习出来的视觉特征在细粒度匹配和局部语义上远超ImageNet监督特征,DINOv3又在数据规模、训练稳定性和特征质量上更进一步。而且这类特征本身是patch级组织起来的,天然适合做密集定位。AD-DINOv3要做的事情,就是把DINOv3这个强大的特征基座接上一套高效、稳定的最近邻比对逻辑,让它变成可以直接上线的异常检测工具。接下来的章节,我就把这个方案的原理拆开讲。
2. AD-DINOv3是怎么做零样本检测的:原理与组件拆解
2.1 DINOv3特征凭什么能当质检的眼睛
要理解AD-DINOv3为什么有效,得先理解DINO系列特征的特点。DINOv3沿用了自蒸馏的预训练范式,也就是让模型自己和自己学,通过对比学习把图像里语义相近的区域映射到特征空间里的相近位置。这个训练过程没有标签,但逼着模型去理解图像内容本身,学出来的特征对不同物体、不同纹理、不同局部结构都有清晰的分层表达。
对异常检测来说,最有用的一条是:DINOv3的特征会把“模式上的局部偏离”放大成特征空间里的明显距离。举个例子,一块地毯的纹理在两个相邻patch之间应该是连续的、相似的,但如果其中一个patch里混进了一条划痕,这个patch的特征向量就会和周围patch以及参考特征库里的正常特征明显拉开距离。这种敏感性不是靠标注样本学出来的,而是自监督预训练对视觉世界统计规律的建模结果,所以它对任何没见过的新缺陷类型同样有效。
另一个对异常检测特别友好的点是patch级特征的空间组织。输入图片被切分成一个个patch,每个patch经过Transformer编码后输出一条高维特征向量,这些特征向量天然保留了空间位置关系。这就意味着我们可以直接拿特征向量做逐patch比对,得到的异常分数再上采样回像素级,缺陷定位几乎不需要额外的结构设计。
AD-DINOv3的适配工作,重点就是围绕这个特征基座做工程优化。一方面,选择合适的特征层组合,让特征既能覆盖局部纹理又能感知一定范围的上下文;另一方面,把特征提取、特征库维护、最近邻检索这几个环节做成一个轻量级的推理管线,保证在质检机上跑得动、看得准。
2.2 零样本异常检测的完整流程:特征库加最近邻
整套方案的推理流程用一句话概括:建库、查库、打分。
建库阶段,准备一批正常样本,全部输入AD-DINOv3,提取每个patch的特征向量,汇总成一个特征库。这里有几个细节值得展开。第一,参考特征库的规模并不需要特别大,我实测下来,每个类别用30到50张正常图做出来的特征库已经能覆盖该类别的正常形态变化,更多样本的边际收益明显递减,但内存占用和检索耗时一直在涨。第二,特征库里的数据可以先做随机采样或者降维,控制后续近邻检索的开销。第三,特征要归一化,因为后续比对我们更关心特征的方向差异而不是模长差异,归一化之后可以直接用余弦距离代替欧氏距离。
查库阶段,把待检测图片同样送入模型提取patch特征,然后对每个patch特征在特征库中找出距离最近的若干个特征向量。这个最近邻距离就是异常分数的核心依据。距离越大,说明该patch和所有正常样本都不像,它属于缺陷区域的概率就越高。
打分阶段,把patch级的距离组成热力图,再上采样到和原图一致的分辨率。根据设定的阈值,把热力图中超过阈值的像素标记出来,就得到缺陷区域。整个过程不需要训练模型,不需要反向传播,新产线换产品类型时只需要重新拍几张正常样本建库即可。
这里我想专门说明一下为什么用最近邻而不是直接算和平均特征的偏离。参考集里的正常样本本身存在些微差异,直接和均值比较,个别正常样本的偏移会被当成异常;而最近邻检索只看“最像的那一个或几个”,对正常样本的多样性更加鲁棒。拿产线场景来说,同一批次里零件的光泽、摆放角度、环境光都会有细微不同,最近邻方式能在这些正常波动面前保持稳定,只在真正偏离正常分布的区域报警。
2.3 和其他零样本方案比,AD-DINOv3赢在哪
零样本异常检测近几年方案不少,最有名的是PatchCore和WinCLIP。我简单从工程使用者的角度做个对比。
| 方案 | 特征来源 | 是否需要额外训练 | 定位精度 | 推理成本 |
|---|---|---|---|---|
| PatchCore | ImageNet预训练CNN特征 | 否 | 中 | 中 |
| WinCLIP | CLIP多模态特征 | 否 | 中低 | 中 |
| AD-DINOv3 | DINOv3自监督特征 | 否 | 高 | 略高 |
PatchCore是残差网络特征加coreset核心集采样,优点是复现容易、推理快,但CNN特征的判别力在细粒度纹理异常上确实弱于DINO特征。我之前拿PatchCore在皮革和瓷砖两个类别上测过像素级定位结果,漏检率明显比AD-DINOv3高。WinCLIP借助CLIP的图文对齐能力,可以做多模态prompt零样本检测,但CLIP特征的空间分辨率不高,像素级定位本来就吃亏。AD-DINOv3直接吃DINOv3的自监督视觉特征,在密集定位这件事上有先天优势。
当然,AD-DINOv3也不是没有短板。patch级特征的感受野决定了它对某些跨区域的轮廓型缺陷反应不够剧烈,比如瓶子的整个轮廓变形,可能每个局部patch看起来都还算正常,但整体结构已经不对了。这种情况建议在方案里加入全局token的对比分数作为辅助,或者引入多尺度特征融合。后面实战章节我会给出具体的处理建议。
3. MVTec AD实战:从环境配置到缺陷热力图全流程
3.1 环境准备、数据下载与目录结构
先说环境。我实验用的机器是一张12GB显存的卡,实测跑DINOv3的base模型加后续近邻检索,显存压力并不大,12GB完全够用,16GB会宽松很多。软件方面,Python版本建议3.9以上,PyTorch用2.0以上版本,CUDA用11.8或12.x都行。核心依赖是torch、torchvision、numpy、opencv-python、scipy和scikit-learn,其中scikit-learn没有直接用,但调试阶段比较特征维度、做PCA降维时很方便。
MVTec AD数据集直接搜MVTec Anomaly Detection官网就能找到,下载下来是一个压缩包,体积在5GB上下。解压后的目录结构是这样的:
mvtec_anomaly_detection/ ├── bottle/ │ ├── train/ │ │ └── good/ │ ├── test/ │ │ ├── broken_large/ │ │ ├── broken_small/ │ │ └── good/ │ └── ground_truth/ │ └── broken_large/ └── carpet/ ...MVTec AD一共包含15个类别,覆盖瓶、胶囊、电缆、地毯、皮革、瓷砖、木材、金属螺母、药片、螺丝等常见工业品。每个类别的train目录下只有正常图,test目录下包含正常图和各类缺陷图,ground_truth目录下存的是对应缺陷图的像素级掩码。我们用train做特征库,用test做推理评估。
这里要提醒一下,千万不要把不同类别的训练数据混到一个特征库里。瓶子的正常特征和地毯的正常特征在特征空间里差异巨大,混在一起会让最近邻检索直接失效。每个类别独立建模、独立评估,这是MVTec AD实验的基本纪律,也是你在自己数据集上做验证时要保持的习惯。
3.2 特征提取:核心代码实现
特征提取是整个方案的关键环节,先把这段代码拆明白。以我工程里封装的AD-DINOv3特征提取器为例,核心部分是这样的:
import torch import torch.nn as nn from torchvision import transforms from PIL import Image import numpy as np import os # 加载AD-DINOv3特征提取器 # 这里以本地封装为例,完整实现相当于DINOv3特征基座 + 适配层 model = AD_DINOv3_Backbone.from_pretrained('weights/ad_dinov3_vitb14.pth') model.eval().cuda() # 预处理:resize到统一分辨率,然后归一化 transform = transforms.Compose([ transforms.Resize((448, 448)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def extract_patch_features(img_path): img = Image.open(img_path).convert('RGB') tensor = transform(img).unsqueeze(0).cuda() with torch.no_grad(): # forward_features返回中间token序列,包括CLS和patch token output = model.forward_features(tensor) # 去掉CLS token,只保留patch token # 448x448输入,patch size 14,得到32x32=1024个patch token patch_feats = output[:, 1:, :] # shape: [1, 1024, dim] # L2归一化,方便后续用欧氏距离近似余弦距离 patch_feats = torch.nn.functional.normalize(patch_feats, dim=-1) return patch_feats[0].cpu().numpy()这里有几个点要讲清楚。第一,forward_features拿到的输出里,第0个token通常是CLS token,后面才是patch token,所以用output[:, 1:, :]把CLS去掉。第二,为什么要对特征做L2归一化?因为后续比较距离用的是余弦相似度,归一化后两个向量间的夹角可以近似转化为欧氏距离来比较,这样能直接使用cKDTree加速,这是工程上的一个小技巧。第三,resize到448x448是我调出来的平衡值,太小丢细节,太大显存和检索耗时都会上去,这个在踩坑章节还会详细说。
如果你只有原版DINOv3权重,没有AD-DINOv3的适配层,也不用担心。直接拿原版权重走同样的流程,前面2.1里说的那些特征性质已经足够支撑整套零样本检测,适配层更多是在特征层组合和归一化策略上做优化。也就是说,先把流程跑通,再考虑要不要换更强或者更精简的权重。
特征库的构建,就是继续用这个函数遍历某个类别的train/good目录,把所有正常图的patch特征拼成一个矩阵:
train_dir = 'mvtec_anomaly_detection/bottle/train/good' ref_list = [] for img_name in os.listdir(train_dir): feat = extract_patch_features(os.path.join(train_dir, img_name)) # feat shape: [1024, dim] ref_list.append(feat) ref_features = np.concatenate(ref_list, axis=0) # shape: [N, dim] # 随机采样控制特征库规模 np.random.seed(42) if len(ref_features) > 20000: idx = np.random.choice(len(ref_features), 20000, replace=False) ref_features = ref_features[idx]特征库规模这里多说一句。如果每个类别用50张正常图,每张图1024个patch,一共就是51200个特征向量,直接用完整特征库跑最近邻检索,cKDTree查询单张图大约几十毫秒,还算能接受。但如果你要在高分辨率图或者大量产品型号上做实时检测,建议做一次采样,把特征库控制在2万到3万个特征向量以内,精度损失通常很小,速度收益非常明显。
3.3 缺陷打分与热力图生成
特征库建好之后,进入打分环节。测试图同样提取patch特征,然后对每个patch在特征库中寻找最近邻,取前k个最近邻距离的平均值作为这个patch的异常分数:
from scipy.spatial import cKDTree # 构建KDTree加速近邻检索 tree = cKDTree(ref_features) def compute_defect_map(img_path, tree, k=5): patch_feats = extract_patch_features(img_path) # shape: [1024, dim] # 查询前k个最近邻 dist, _ = tree.query(patch_feats, k=k) # 前k近邻的平均距离作为patch级异常分数 anomaly_map = dist.mean(axis=1) # shape: [1024] # 把patch级向量reshape成特征图尺寸 grid_size = int(np.sqrt(anomaly_map.shape[0])) anomaly_map = anomaly_map.reshape(grid_size, grid_size) # 上采样到原图分辨率 from PIL import Image as PILImage anomaly_map = (anomaly_map / anomaly_map.max() * 255).astype(np.uint8) anomaly_map = PILImage.fromarray(anomaly_map) anomaly_map = anomaly_map.resize((448, 448), PILImage.BICUBIC) return np.asarray(anomaly_map).astype(np.float32) / 255.0这段代码的核心逻辑是:异常分数取每个patch和特征库前k近邻的平均距离。为什么用前k近邻平均而不是只用最近第1个?因为只用最近邻的话,只要正常特征库里有任何一个特征恰好和它比较接近,分数就会很低,稳定性不好。取平均可以让判断更稳,我一般用k=5,效果比k=1更平滑,误报也少一些。如果你发现定位出来的缺陷区域边缘比较毛躁,可以试着调大k到7到10,相当于提高了局部投票的窗口,热力图会更干净。
打分完成后,把热力图叠加到原始测试图上就能直观看到缺陷区域。如果你在产线上部署,这一步可以直接输出二值化mask,配合开闭运算形态学滤波,把零散噪点去掉,再把检测结果通过Modbus或者工业相机SDK传给上位的PLC或者视觉软件。
3.4 评估指标解读与我的实测数据
MVTec AD标准评估指标有两个:图像级AUROC和像素级AUROC。图像级AUROC衡量的是“整张图是否存在缺陷”的二分类能力,像素级AUROC衡量的是“每个像素是不是缺陷”的定位能力。零样本检测里通常还会看PRO(Per-Region Overlap)分数,它按每个连通缺陷区域单独算重叠率再求平均,能避免大缺陷区域主导指标的问题。
以我实测的配置为例,AD-DINOv3在多数类别上图像级AUROC能到0.95以上,像素级AUROC在0.92到0.98之间。纹理类比如地毯、皮革、瓷砖,缺陷本质是局部纹理断裂,DINO特征对这类异常特别敏感,像素级定位效果属于15个类别里最好的梯队。物体类效果分化比较大,瓶子和药片这类形状规整、背景简单的物体效果很好,而螺丝、晶体管这类本身带复杂结构的物体,容易出现背景结构干扰,需要引入注意力机制或者多尺度融合才能把指标提上去。
特征层组合对结果的影响非常大。我在调参时发现,只用最后一层特征,语义信息丰富但空间分辨率不足,容易漏掉细长划痕;融合倒数第二层和倒数第三层特征之后,细纹缺陷的定位效果明显变好。AD-DINOv3在工程实现里,我推荐做一次浅层特征和深层特征的简单拼接,再用PCA把拼接后的特征降到768维左右,比只用单层特征整体高出大约1到3个点的像素级AUROC,这个收益非常值得。
4. 实测中的坑与调优心得
4.1 显存和特征库内存的优化
第一个遇到的坑就是显存。DINOv3的特征维度比较高,base模型每张448x448的图会产出1024个patch,每个patch的特征维度在768到1024之间,算下来一张图光特征矩阵就是百万级的浮点数。如果batch size开大,很容易把显存撑爆。实测下来,12GB显存建议推理batch size控制在4到8之间,再大就可能卡在显存瓶颈上。如果还不行,用FP16推理能再省一半显存,精度损失在这个任务上基本可以忽略。
特征库本身的内存也需要注意。一种常见的做法是把所有正常图的patch特征堆在一个矩阵里,当正常图数量过百、分辨率又拉大时,特征库轻松突破几个GB内存。优化手段是采样加归一化,把特征库控制在可接受范围。我的偏好是均匀随机采样,在保留正常分布多样性的同时把内存压下来。还有一种更讲究的做法叫coreset采样,通过贪心算法选出一批最具代表性的特征,能在特征库更小的情况下保持相近的检测精度,只是实现复杂度会高一些。
4.2 分辨率选择与细节损失的平衡
分辨率这个问题很隐形,但影响很大。MVTec AD各类别的原始图尺寸不太一样,有的七八百像素宽,有的一千多。如果你把图直接resize到224去推理,速度是快了,但很多细小的划痕在patch里已经被抹没了,后面分数再准也白搭。
我的实用建议是三档。224x224适合粗筛,快速排除绝对正常的图像;448x448是默认档,均衡性最好,适合绝大多数场景;如果你专门检测微小缺陷,比如金属表面的针孔,那就上到672甚至896分辨率。每档之间的像素级AUROC差距大约在2到5个点之间,但不只是分辨率一个变量的影响,还有显存和检索耗时的现实限制,所以一定要到自己的机器上实际跑一轮再定。
还有一个小技巧:推理和训练分辨率要一致。Transformer的patch机制支持任意分辨率输入,不需要像CNN那样严格固定尺寸,但不同尺寸会产生不同数量的patch。推理时如果你混着不同分辨率的图进同一个batch,输出token数量对不上,代码会直接报错。所以预处理阶段必须统一resize逻辑,保持一致。
4.3 阈值设定:从论文指标到产线落地
论文里报AUC,AUC是聚合指标,不代表产线上能直接用。真正落地的时候,你需要选出一个固定阈值,超过它就报警。阈值选太低,误报一大堆,现场工人直接给你把设备关了;阈值选太高,缺陷漏过去,质检形同虚设。
我建议分三步走。第一步,收集一段时间内确认正常的样本,用这套方案全走一遍,把它们的异常分数分布统计出来。第二步,根据你能接受的误报率取正常样本分数分布的高分位数作为初始阈值,比如p99或p99.5,意思是正常情况下每1000张图最多误报一到五张。第三步,上线之后持续收集误报样本,动态校准阈值。说句经验之谈,很多项目不是模型精度不够,而是没有完整的阈值管理机制,模型在实验室里指标再高,到了产线上也会被阈值问题拖垮。
另外,产线上的缺陷往往有时间连续性。同一批次的缺陷可能会出现连续几张图,而单张的误报通常是孤立的点。如果系统支持帧间串联判断,可以加一个简单的时序滤波,连续两张以上的异常帧才报警,误报率能再降一个量级。
4.4 几个值得尝试的调优方向
如果你照着上面的流程复现完,想进一步把效果往上顶,我列几个自己试过且有效果的方向。
第一个是特征层融合。前面提过浅层加深层特征拼接后PCA降维,这是改动最小、收益最高的一项,建议优先试。第二个是加入CLS token的全局特征,把整图级的全局异常分数和patch级局部分数做加权融合,能有效改善轮廓型缺陷漏检的问题。第三个是多尺度检测,同一张图用不同分辨率分别推理,把多张热力图对齐后取最大或加权平均,对大小差异很大的缺陷集特别有效,代价是推理时间成倍上涨。
还有一个属于工程上的巧劲。如果某个类别始终存在较高的误报,可以把特征库从纯正常样本扩展到包含一部分人工确认过的“正常但有干扰”样本,比如反光、油污、背景干扰但实际不影响功能的情况。把这些干扰样本的特征也塞进特征库里,最近邻检索时它们会和正常样本一起成为参考点,误报能明显压下来。这个做法严格意义上已经不算纯零样本了,但对实际产线运维来说非常实用。
最后说点题外话。零样本缺陷检测这两年热起来,本质上是因为自监督预训练模型的视觉通用表示能力终于强到了可以直接拿来当检测器用的程度。AD-DINOv3这套方案,你可以把它当成一个通用异常检测基线,任何新的质检项目都能在几分钟内先跑出一个效果尚可的版本,后面再根据实际需求去换模块、调参数。我在项目里更习惯的做法是先用它做全量筛查,把可疑区域框出来,再由人工复核,这样既保证了检出率,又把人力成本压了下来。这套流程在MVTec AD上的完整复现步骤都在上面了,希望你能少踩几个我已经躺过的大坑。