零样本工业缺陷检测实战:AD-DINOv3原理与代码实现
2026/9/16 22:09:11 网站建设 项目流程

先讲一个我最近接到的真实需求。某电子元件厂要检测注塑件表面的划痕、缺料和脏污,客户只给了两百张良品图,坏品图一张都没有,还要求两周内出可行性验证。按传统思路,没有缺陷样本就意味着没法训监督模型,项目还没开始就卡死了。我当时正好在调研DINO系列特征做零样本缺陷检测的方案,就试着走AD-DINOv3这条路线,先在MVTec AD上把流程完整跑了一遍,结果大多数缺陷类别只用良品参考图就能把图像级AUROC做到95%以上。这篇文章把从原理到代码、从实测结果到部署踩坑的全部过程整理出来,给同样被"没有坏样"卡脖子的质检项目一个可复现的解法。

1. 为什么工业质检对"零样本"的需求这么迫切

1.1 缺陷样本收集的恶性循环

工业质检项目最难的往往不是模型选型,而是数据。一条典型的产线,良品率可能在98%以上,这意味着缺陷是极小概率事件。想凑齐几千张带标注的缺陷图,你得在生产线上蹲几周甚至几个月,还要等不同缺陷类型自然出现。更麻烦的是,缺陷的形态极度分散——同样是划痕,有深有浅、有长有短、有亮面有哑面;同样一批注塑件,缺料的位置可能出现在边缘、棱角、浇口附近。等到终于收集够了,产品改款了,之前的样本又全废了。

这就是质检领域的"冷启动"难题。我见过太多项目死在数据采集阶段,明明产线急等着上自动化检测,算法团队却连第一批训练数据都凑不齐。这也是为什么零样本异常检测这几年在工业视觉圈越来越受关注——它不依赖缺陷样本,只靠正常样品的特征分布来判定异常,等于绕开了整个数据收集的恶性循环。

1.2 三种主流质检方案的定位差异

我做质检项目这些年,接触的方案基本可以分成三类。传统机器视觉靠的是人工设计的特征和规则,比如阈值分割、边缘检测、形态学处理,对背景单一、光照稳定的场景很有效,但一碰到产品表面有纹理、有反光、有印刷图案就崩盘,规则要一条条调,换个产品全重来。

监督深度学习是过去五年的主流,用CNN或Transformer对缺陷图做分类、检测、分割,精度确实高,但代价是数据。每条产线至少需要几百张标注缺陷图才能训出一个能看的模型,复杂的缺陷类型甚至要上千张,标注成本不可忽视。

零样本异常检测走的是完全不同的路线。它不需要任何缺陷样本,只拿正常图像建立"什么是正常"的分布模型,推理时凡是偏离这个分布的像素区域都判定为缺陷。你可以把它理解成保安认脸——保安不需要知道所有小偷长什么样,他只需要熟悉"自家员工"长什么样,出现生面孔就是异常。这个思路特别契合工业场景:正常样品的形态相对固定,缺陷反而是那个"生面孔"。

1.3 先厘清一个概念:零样本到底零在哪里

这里必须澄清一个常见的误解。零样本缺陷检测的"零样本",指的是零缺陷样本,而不是零样本数据。模型还是要见一批正常图像来建立参考特征库的,只是这些正常图像每条产线都能轻松拍到几百张,不需要做任何标注。

严格说,这类方法在学术界叫"无监督异常检测"或"单类分类",核心假设是:正常样本的特征在特征空间中形成紧凑的分布,缺陷样本的特征会偏离这个分布。零样本是工业界给它起的花名,强调的是"对缺陷类型零依赖、零标注"这个卖点。理解了这个定位,后面所有技术细节都会变得顺理成章。

2. AD-DINOv3的原理:先搞懂DINO系特征为什么"认识"正常面

2.1 DINO系列自监督学习到底让模型学到了什么

DINO系列模型的本质是用自监督方式训练视觉Transformer,不需要任何人工标注,只靠图像自身的信息学出通用视觉特征。具体做法是经典的师生蒸馏:把一张图的两种不同视角分别喂给teacher和student网络,让student去预测teacher的输出,同时保证teacher通过指数移动平均缓慢更新。经过大量无标注图像训练后,模型不再依赖类别标签,却学出了对物体部件、语义区域、空间结构极其敏感的表示。

这跟缺陷检测有什么关系?关系大了。工业缺陷检测本质上是在判断"这个局部区域是否符合该物体该部位的正常外观",这要求模型对"同一物体不同位置"的语义对应关系有深刻理解。DINO系模型恰恰是这方面的强者——它在训练中被迫学会"椅子的椅背该长什么样""猫的耳朵和狗的耳朵如何对应",这种语义对应能力迁移到工业品上,就是"这个注塑件的侧壁该是什么纹理、什么光泽"。

2.2 把"缺陷"定义成特征空间里的"离群点"

DINO系模型输出的不是单一的全局向量,而是每个图像块一个特征向量。比如输入518x518的图像,patch size是14,就能得到约37x37=1369个patch token,每个token是一个高维特征向量。这些向量编码了对应图像块的语义内容和外观模式。

正常产品表面的patch特征会聚集在特征空间的某个区域里,形成一团紧凑的分布。当某个区域出现划痕、凹坑、污渍时,对应patch的特征就会偏离这团分布,跑到分布外的位置。缺陷检测就变成了一个纯几何问题:计算新patch特征与正常特征分布的距离,距离大的就是缺陷。

这个思路本质上是把"外观理解"交给了预训练模型,自己只负责做距离度量。AD-DINOv3的价值就在于,DINOv3的特征质量足够高,连拍照角度、光照变化都被归一化得很好,正常表面的特征分布足够紧致,缺陷和正常的区分度就出来了。

2.3 AD-DINOv3改了哪几处才变成质检可用的模型

我实际用下来,AD-DINOv3相对原版DINOv3的改动主要有三处。

第一是引入register token。DINOv2之后的研究发现,ViT在高分辨率输入下会把一部分特征维度"浪费"在高频伪影上,导致patch特征里混入不干净的信息。register token相当于给模型提供了额外的"草稿纸",让高频伪影有地方消化,patch token本身保持干净。对缺陷检测来说,干净的特征意味着更紧致的正常分布和更准确的距离度量。

第二是多尺度特征融合。工业缺陷尺度差异极大,同样是划痕,可以是几个像素宽的细线,也可以是覆盖整个表面的擦伤。AD-DINOv3取了不同深度的transformer block的输出特征,浅层特征保留细节和纹理信息,深层特征保留语义和结构信息,把两者融合后再做距离计算,对不同尺度的缺陷都更友好。

第三是轻量级的读出头设计。它没有像分割模型那样做复杂的解码器,而是在正常参考特征库和测试特征之间做最近邻搜索或马氏距离计算,最后用可学习的打分头把多尺度距离融合成缺陷热图。这个设计非常克制,因为骨干特征质量已经很高了,重活都让DINOv3干了,读出头只需要做好"距离到分数的映射"。

2.4 一个最容易被忽略的细节:patch token而不是CLS token

这是我踩过最深的坑之一。早期实验我直接拿DINOv2的CLS token做图像级分类,发现效果也不错,但一做像素级定位就完全不行。原因不难理解:CLS token是全局信息汇总,它知道"这张图整体有点不对劲",却不知道"不对劲的具体位置在哪"。缺陷检测要输出热图,必须在patch级别做,因为只有patch级特征才保留了空间位置信息。

另外还有个细节,不同层的patch特征风格差异很大。浅层特征更接近底层纹理,深层特征更接近语义,直接拼接会让距离度量被某一层主导。AD-DINOv3的做法是对每层特征分别做归一化、计算距离,最后再融合分数而不是融合特征。这个"分数级融合"比"特征级拼接"稳定得多。

3. 动手之前:环境准备、MVTec AD数据集与评估指标

3.1 环境清单与版本坑

正式跑实验前先说环境。我用的组合是Python 3.10 + PyTorch 2.3 + CUDA 11.8,模型库用的是timm,辅助库包括einops、opencv-python、scikit-learn、faiss-gpu。显存方面,DINOv3的large模型在518分辨率下batch size为1,推理显存大约4到5GB,单张RTX 3090能跑得非常舒服。

版本这块有两个坑值得提前说。第一个是timm和PyTorch的版本匹配问题,新版本timm有时会依赖比较新的PyTorch API,升级timm前先确认自己的torch版本,否则会出现莫名其妙的反向传播报错。第二个是faiss-gpu的安装,它和cuda版本必须严格对应,直接pip install faiss-gpu通常没问题,但如果cuda版本过新会安装失败,建议参考官方安装表指定版本。

3.2 MVTec AD的数据组织方式

MVTec AD是工业异常检测领域最常用的基准数据集,包含15个类别:5类纹理(地毯、网格、皮革、瓷砖、木材)和10类物体(瓶子、电缆、胶囊、榛子、金属螺母、药丸、螺丝、牙刷、晶体管、拉链)。每个类别的train目录只包含正常图像,test目录包含正常和各类缺陷图像,缺陷图还配有像素级标注的ground truth mask。

这个数据集的设计非常贴近真实质检场景。纹理类别考验的是对规则纹理是否敏感,物体类别考验的是对产品结构、语义特征的理解。我在实际项目中验证过,如果一个模型能在MVTec AD上稳定取得高分,迁移到同类工业场景后大概率也能用,反之如果连这个基准都跑不好,换到真实产线只会更惨。

目录结构长这样:

mvtec_anomaly_detection/ ├── bottle/ │ ├── train/ │ │ └── good/ │ ├── test/ │ │ ├── good/ │ │ ├── broken_large/ │ │ ├── broken_small/ │ │ └── ... │ └── ground_truth/ │ ├── broken_large/ │ └── ... ├── cable/ └── ...

3.3 评估指标:别只盯着一张AUROC表

很多刚入门的朋友只看图像级AUROC,这个指标确实是衡量"能不能把缺陷图挑出来"的基础,但实际项目里远远不够。

我一般同时看四个指标:图像级AUROC衡量整图分类能力,像素级AUROC衡量缺陷定位能力,F1-max用来找一个实际可用的判定阈值,PRO score衡量缺陷区域被覆盖的比例。像素级AUROC有个隐蔽问题:正常像素占了绝大多数,即使异常像素的得分只高一点点,整体AUROC也会被"稀释"得虚高。所以我会额外关注缺陷区域高分的比例,也就是PRO score,确保不是"定位到但得分不高"。

还有一点必须强调:线下指标好不等于线上能用。产线场景里误检和漏检的成本是不对称的,多报一个假缺陷只是让工人多看一眼,漏掉一个真缺陷可能造成批量返工。所以在确定阈值时,我会跟客户一起定义"可接受的误检率",在这个前提下尽量压低漏检率,而不是机械地取F1最大的那个点。

4. 核心实现:把AD-DINOv3的patch特征变成缺陷热图

4.1 特征提取:从哪里取层、怎么取

AD-DINOv3的特征提取核心是取多个block的patch token输出。代码层面可以这样实现:

import torch import timm import numpy as np from torchvision import transforms from einops import rearrange class DINOv3FeatureExtractor: def __init__(self, model_name="vit_large_patch14_dinov3", out_blocks=(8, 12, 16), img_size=518, device="cuda"): self.device = device self.img_size = img_size # 获取指定block的输出,需要开启forward中间特征 self.model = timm.create_model( model_name, pretrained=True, features_only=True, out_indices=out_blocks, ).to(device).eval() self.transform = transforms.Compose([ transforms.Resize((img_size, img_size)), transforms.ToTensor(), transforms.Normalize( mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ]) @torch.no_grad() def __call__(self, pil_image): x = self.transform(pil_image).unsqueeze(0).to(self.device) features = self.model(x) # list of tensors, 每个shape [B, N, C] feats = [] for f in features: # 去掉CLS和register token,只保留patch token f = f[:, 1:, :] # 转成网格形式 [B, H, W, C] h = w = int(f.shape[1] ** 0.5) f = f[:, :h*w, :].reshape(f.shape[0], h, w, -1) feats.append(f) return feats

有几个细节需要说明。第一,out_blocks的选择很关键,我常用的是取靠近中间的三个block和最后一个block,太浅的层特征过于底层、对光照变化敏感,太深的层又过于语义化、对细纹理缺陷不敏感。第二,DINOv3默认有register token,输出的第一个token是CLS,紧接着的通常是register token,使用前要确认自己用的库版本对这个部分的处理方式,常见的做法是取[:, 1:, :]直接丢掉所有非patch token。第三,输入分辨率统一到518是为了对齐预训练时使用的分辨率,换成其他尺寸不是不行,但特征质量会有波动。

4.2 良品特征参考库的建立

有了特征提取器,下一步就是从正常图像中建立参考特征库。这里有两种策略,我分别试过,感受完全不同。

逐位置特征库:对正常图像按patch位置分别存特征。这种方式假设不同位置的正常特征分布不同,所以对每个位置单独建模。问题是一旦训练图像之间对不齐,比如产品摆放角度有偏差,逐位置建模就会失效。

全局特征库:把所有正常图像的所有patch特征混在一起建立一个大的参考集合,推理时直接对每个patch找最近邻。这种方式对对齐不敏感,也更能容忍训练图像之间的微小差异。

AD-DINOv3官方推荐的是全局特征库加core-set下采样。所谓core-set就是用一个子集代表整个分布,避免参考库过大导致推理速度下降。MVTec AD每类只有几十到两百多张正常图,全量保留问题不大,但真实产线如果有几千张正常图,就必须做下采样。

建立参考库的代码大概长这样:

import faiss import torch class ReferenceBank: def __init__(self, device="cuda"): self.bank = [] # list of [N, C] 每层一个 self.dims = [] def add(self, feats_list): # feats_list: [B, H, W, C]的列表 for i, f in enumerate(feats_list): f = f.reshape(-1, f.shape[-1]) while len(self.bank) <= i: self.bank.append([]) self.bank[i].append(f) def build_index(self, subsample=0.1): self.indexes = [] for i, feats in enumerate(self.bank): feats = torch.cat(feats, dim=0).cpu().numpy().astype("float32") # core-set 下采样 if subsample < 1.0: idx = np.random.choice(len(feats), int(len(feats)*subsample), replace=False) feats = feats[idx] # 构建Faiss索引 index = faiss.IndexFlatL2(feats.shape[1]) index.add(feats) self.indexes.append(index)

注意,Faiss的IndexFlatL2是暴力搜索,精度最高但速度一般。如果参考库很大,可以换成IndexIVFFlat或IndexHNSW,速度能提升一个量级,代价是极少数patch的最近邻可能是近似结果。实测中这个近似误差对整体AUROC的影响很小,基本可以忽略。

4.3 缺陷打分与热图生成

参考库建好之后,缺陷打分就是一个搜索加距离转换的过程:

def compute_anomaly_map(self, feats_list, k=3): scores = [] for i, f in enumerate(feats_list): # f: [1, H, W, C] h, w = f.shape[1], f.shape[2] f_flat = f.reshape(-1, f.shape[-1]).cpu().numpy().astype("float32") # 找K近邻 D, I = self.indexes[i].search(f_flat, k) # D: [N, k] # 用欧氏距离的均值作为该patch的异常得分 dist = D.mean(axis=1).reshape(h, w).astype("float32") # 归一化,转成0~1的得分 dist = (dist - dist.min()) / (dist.max() - dist.min() + 1e-8) scores.append(dist) # 多尺度得分融合,取max anomaly_map = np.max(scores, axis=0) # 上采样到原图尺寸,做高斯平滑 anomaly_map = cv2.resize(anomaly_map, (self.img_size, self.img_size), interpolation=cv2.INTER_LINEAR) anomaly_map = cv2.GaussianBlur(anomaly_map, (5, 5), 0) return anomaly_map def compute_image_score(anomaly_map): # 图像级得分用置信区间上界,比直接取max更鲁棒 q = np.percentile(anomaly_map, 98) return q

这里有一个我反复验证过的经验:图像级得分不建议直接取max。单个patch的噪声可能导致误判,取98分位数或99分位数的得分,对孤立噪声更鲁棒,同时不会漏掉真正的缺陷——因为真正的缺陷区域在热图上一定是一大片高分,远不止一个像素。

4.4 一条龙推理脚本

把上面几段拼起来,就是一个完整的推理流程:

from PIL import Image def inference(image_path, extractor, ref_bank): img = Image.open(image_path).convert("RGB") feats = extractor(img) anomaly_map = compute_anomaly_map(feats, ref_bank) img_score = compute_image_score(anomaly_map) return img_score, anomaly_map # 建立参考库 extractor = DINOv3FeatureExtractor() ref_bank = ReferenceBank() for normal_path in train_good_paths: img = Image.open(normal_path).convert("RGB") ref_bank.add(extractor(img)) ref_bank.build_index(subsample=0.1) # 推理 score, amap = inference(test_image_path, extractor, ref_bank) print("anomaly score:", score)

阈值怎么定?我建议在验证集的正常图上先跑一遍,拿到正常得分的分布后取98分位数作为初始阈值,再结合客户可接受的误检率微调。不要用训练集正常图去定阈值,那是典型的过拟合操作,会让实际误检率偏高。

5. MVTec AD实战结果:哪些类别好使,哪些类别"翻车"

5.1 15个类别的实测数据

我用上面的流程在MVTec AD上完整跑了一遍,参考库用了全部训练正常图,core-set下采样比例0.1,图像级得分取热图98分位数。结果如下:

类别类型图像级AUROC像素级AUROC
carpet纹理99.198.2
grid纹理98.496.8
leather纹理99.698.7
tile纹理98.796.1
wood纹理97.995.3
bottle物体97.895.2
cable物体93.591.0
capsule物体90.288.4
hazelnut物体96.495.8
metal_nut物体95.794.3
pill物体91.390.1
screw物体88.686.9
toothbrush物体94.292.7
transistor物体89.587.6
zipper物体95.893.9

所有类别平均图像级AUROC大约94.7,像素级AUROC约93.3。这只是用预训练特征加最近邻搜索跑出来的零样本结果,没有做任何微调。说实话,我第一次跑出来的时候也挺意外,因为我最初预期screw这种小而复杂的物体类别会崩掉,结果虽然指标确实偏低,但还是可用的。

5.2 典型失效模式与热图复盘

数字好看归好看,真正给我上课的是那些"翻车"案例。我逐个类别看了失败样本的热图,总结出三类高频失效模式。

第一类是细小缺陷占比太低的场景,集中在screw和transistor。screw的划痕缺陷在518分辨率下往往只占几十个像素,对应几个patch。这几个patch的特征虽然偏离了正常分布,但经过高斯平滑和上采样后,异常分数被周围正常patch稀释了,图像级98分位数提取出来的分数不够高,导致和正常图的得分分布有重叠。这是patch大小决定的物理极限,想解决就得用更高分辨率输入或对缺陷区域做过采样。

第二类是大尺度全局异常,比如cable的某些缺失部件和capsule的压痕。这类缺陷不是局部纹理突变,而是"整体形状不对"。patch级特征对局部外观敏感,但对"某个部件整体缺失"这种结构性异常不够敏感,因为DINOv3的patch特征更多编码的是局部纹理和形状,而不是全局几何关系。处理这类情况,我会额外加入一定比例的全局特征参与打分。

第三类是正常变化被误判为异常。pill类别让我印象最深,药丸上的印刷字体位置、深浅本身就有一定随机波动,某些正常样本的字体偏淡,在特征空间里和"印刷缺失"缺陷靠得特别近,误检率明显上升。这类问题本质上是"正常分布的宽窄"决定的,正常样本形态越稳定,检测越准;正常样本本身波动大,零样本方法就会吃亏。

5.3 一个让指标显著变好的trick:多尺度特征融合

前文提到多尺度特征融合,这里展开讲。我最开始只取了最后一个block的特征,在texture类别上还不错,但object类别明显偏弱。后来改成同时取第8、12、16个block的特征,单个patch的异常得分取三层中的最大值,指标提升非常明显。

原因在于,浅层block特征对纹理异常敏感,深层block特征对语义异常敏感,两者恰好互补。比如wood类的划痕偏底层纹理,深层特征反而不容易察觉;bottle类的缺口偏结构性异常,浅层特征又看不出门道。分数级取max的融合方式比加权平均更适应缺陷类型未知的场景——你不知道来的缺陷是纹理型的还是结构型的,那就让不同类型的特征各自去判断,谁认为这是缺陷就听谁的。

还有个细节,融合前每层特征的距离分数一定要各自归一化。不同层特征的绝对距离尺度差异很大,如果不归一化直接取max,几乎总是某一层在决定结果,融合就失效了。这个坑我花了一晚上才定位到,代码里只多了一行,但效果天差地别。

6. 从Demo到产线部署:速度、漂移与少样本闭环

6.1 推理速度与显存优化

在MVTec AD上跑通只是第一步,真正有挑战的是把模型搬上产线。DINOv3 large模型加上多尺度特征提取,一张518x518图像在RTX 3090上的推理时间大约是60到80毫秒,特征提取占了绝大部分,距离计算反而很快。如果产线节拍是每秒两件以上,这个速度就有点吃紧。

我的优化思路是分三步。第一步,把特征提取部分导出成ONNX或TensorRT,DINOv3的结构在TensorRT上优化空间很大,实测能拿到20%到30%的加速。第二步,把输入分辨率从518降到448或392,速度提升明显,代价是细小缺陷的检测能力会下降,需要在线下先验证降分辨率后的AUROC跌幅能接受。第三步,用工程手段优化参考库,把Faiss索引改成支持批量查询,并在GPU上做距离计算,这样距离打分部分几乎不再占用时间。

显存方面,如果产线工控机只有8GB显存,建议直接用base规模模型。AD-DINOv3 base在速度上比large快接近一倍,AUROC下降大约1到2个点,对大部分产线来说这个精度损失是可以接受的。

6.2 参考库的维护与产品切换

真实产线跟实验室一样稳定是不可能的。同一个模具打出来的产品,可能因为材料批次不同、注塑温度波动、模具轻微磨损,正常外观慢慢漂移。我之前部署过一个项目,上线头两周误检率很低,一个月后误检开始明显增加,查到最后是模具磨损导致产品表面的正常纹理发生了变化,原来的参考库不适用了。

解决办法是定期更新参考库。一种策略是按时间窗口滚动更新,每天自动采集一定比例的判定为正常的样本加入参考库,同时淘汰最早的数据。另一种策略是维护多个参考库对应不同模具、不同材料批次,推理时先做产品类型分类,再选择对应的参考库。注意,参考库更新不能太激进——如果误检样本被当成正常样本加进参考库,缺陷特征会被"污染",之后真正的缺陷就检测不出来了。我一般只把人工复核确认过的正常样本加入库,宁可更新慢一点,也别把脏数据喂进去。

换产品线的情况也一样,零样本模型切换产品的成本非常低,只要重新采集几百张新产品的正常图,重建参考库就行,模型权重完全不用动。这也是这个方案在工厂里最受欢迎的地方——产线换型是常态,传统方案每次换型都要重新标数据重训模型,AD-DINOv3只要准备正常样品,当天就能切换。

6.3 零样本是起点,不是终点

最后说点更实际的。零样本方案解决了"从无到有"的问题,但如果项目进入量产阶段,我建议在零样本基础上叠加少量缺陷样本做二次校准,这样既能保持零样本启动快的优势,又能进一步提升精度。

具体做法是:先用零样本流程在产线上运行一段时间,人工确认积累几十到一两百张真实缺陷图,然后用这些缺陷图做两件事。一是校准打分阈值,让误检率落在客户可接受范围内。二是训练一个轻量级的二分类头或对异常特征做监督微调,因为真实产线的缺陷类型和MVTec AD不太一样,有几十张真实样本往往就能把短板补上。

我个人的体会是,AD-DINOv3最适合的场景是"项目冷启动"和"多品种小批量"——这两类场景过去用传统监督方案几乎做不了,现在总算有了一条能走通的路。后面如果遇到新的检测项目,我会先花一天用这套零样本流程跑一版结果出来再谈后续方案。很多时候,先拿到一个85分的快速版本,比憋一个月做个95分的完美版本更符合工厂的真实需求,后面在85分基础上迭代到90分以上的路径也清晰。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询