零样本工业缺陷检测实战:AD-DINOv3原理与代码实现

零样本工业缺陷检测实战:AD-DINOv3原理与代码实现 先讲一个我最近接到的真实需求。某电子元件厂要检测注塑件表面的划痕、缺料和脏污客户只给了两百张良品图坏品图一张都没有还要求两周内出可行性验证。按传统思路没有缺陷样本就意味着没法训监督模型项目还没开始就卡死了。我当时正好在调研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就能得到约37x371369个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_namevit_large_patch14_dinov3, out_blocks(8, 12, 16), img_size518, devicecuda): self.device device self.img_size img_size # 获取指定block的输出需要开启forward中间特征 self.model timm.create_model( model_name, pretrainedTrue, features_onlyTrue, out_indicesout_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, devicecuda): 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, subsample0.1): self.indexes [] for i, feats in enumerate(self.bank): feats torch.cat(feats, dim0).cpu().numpy().astype(float32) # core-set 下采样 if subsample 1.0: idx np.random.choice(len(feats), int(len(feats)*subsample), replaceFalse) 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, k3): 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(axis1).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, axis0) # 上采样到原图尺寸做高斯平滑 anomaly_map cv2.resize(anomaly_map, (self.img_size, self.img_size), interpolationcv2.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(subsample0.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像素级AUROCcarpet纹理99.198.2grid纹理98.496.8leather纹理99.698.7tile纹理98.796.1wood纹理97.995.3bottle物体97.895.2cable物体93.591.0capsule物体90.288.4hazelnut物体96.495.8metal_nut物体95.794.3pill物体91.390.1screw物体88.686.9toothbrush物体94.292.7transistor物体89.587.6zipper物体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或TensorRTDINOv3的结构在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分以上的路径也清晰。