
🔍 1. 开创性地将DINOv3适配于零样本异常检测(ZSAD)任务
论文首次将DINOv3这一强大的自监督视觉基础模型作为视觉主干网络(visual backbone)引入到零样本异常检测(Zero-Shot Anomaly Detection, ZSAD) 领域。这为ZSAD提供了一种新的思路和强大的特征提取器。
🧠 2. 提出跨模态对比学习(CMCL)策略与轻量级适配器
为了克服DINOv3在自然图像预训练中获得的特征与异常检测任务存在的领域偏差(Domain Bias),研究团队设计了一种跨模态对比学习(Cross Modal Contrastive Learning, CMCL) 策略。
👁️ 3. 设计新颖的异常感知校准模块(AACM)
DINOv3等预训练模型的表征存在一个固有倾向:更关注全局的、高级别的物体语义,而常常会忽略细微的、局部的异常特征。为了解决这个问题,论文创新性地提出了异常感知校准模块(Anomaly-Aware Calibration Module, AACM)。
🏆 4. 广泛的实验验证与卓越的性能表现
论文通过在八个工业(如MVTec AD, VisA)和医疗(如ISIC, ClinicDB)基准数据集上进行大量实验,充分验证了AD-DINOv3的有效性和通用性。
博主简介

AI小怪兽 | 计算机视觉布道者 | 视觉检测领域创新者
深耕计算机视觉与深度学习领域,专注于视觉检测前沿技术的探索与突破。长期致力于YOLO系列算法的结构性创新、性能极限优化与工业级落地实践,旨在打通从学术研究到产业应用的最后一公里。
🚀 核心专长与技术创新
🏆 行业影响力与商业实践
💡 未来方向与使命
秉持 “让每一行代码都有温度” 的技术理念,未来将持续聚焦于实时检测、语义分割及工业缺陷检测的商业化闭环等核心方向。愿与业界同仁协同创新,共同推动技术边界,以坚实的技术能力赋能实体经济与行业变革。

论文:https://arxiv.org/pdf/2509.14084
代码:https://github.com/Kaisor-Yuan/AD-DINOv3
摘要:零样本异常检测(ZSAD)旨在识别任意新类别中的异常样本,提供了一种可扩展且标注高效的解决方案。传统ZSAD方法大多基于CLIP模型,通过计算视觉嵌入与文本嵌入的相似性进行异常检测。近期,DINOv3等视觉基础模型展现出强大的可迁移表征能力。本研究首次将DINOv3适配于ZSAD任务,但面临两大挑战:(i)大规模预训练数据与异常检测任务间的领域偏差导致特征错位;(ii)预训练表征固有的全局语义偏向性,常使细微异常被误判为正常前景对象而非异常区域。为克服这些挑战,我们提出AD-DINOv3——一个专为ZSAD设计的视觉-语言多模态框架。具体而言,我们将异常检测构建为多模态对比学习问题:采用DINOv3作为视觉骨干网络提取图像块令牌和CLS令牌,同时利用CLIP文本编码器生成正常/异常提示词的嵌入表示。为弥合领域差距,我们在双模态中引入轻量级适配器,使其表征能够针对异常检测任务进行重校准。在基础对齐之上,我们进一步设计异常感知校准模块(AACM),显式引导CLS令牌关注异常区域而非通用前景语义,从而增强判别能力。最终通过计算适配后视觉特征与提示词嵌入的相似度实现异常定位。在八个工业及医疗基准数据集上的大量实验表明,AD-DINOv3持续达到或超越现有最先进方法,验证了其作为通用零样本异常检测框架的有效性与广泛适用性。
核心源码介绍:
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This software may be used and distributed in accordance with
# the terms of the DINOv3 License Agreement.
import os
from enum import Enum
from typing import List, Optional, Union
from urllib.parse import urlparse
from pathlib import Path
import torch
from .utils import DINOV3_BASE_URL
class Weights(Enum):
LVD1689M = "LVD1689M"
SAT493M = "SAT493M"
def is_url(path: str) -> bool:
parsed = urlparse(path)
return parsed.scheme in ("https", "file")
def convert_path_or_url_to_url(path: str) -> str:
if is_url(path):
return path
return Path(path).expanduser().resolve().as_uri()
def _make_dinov3_vit_model_arch(
*,
patch_size: int = 16,
compact_arch_name: str = "vitb",
):
if "plus" in compact_arch_name:
model_arch = compact_arch_name.replace("plus", f"{patch_size}plus")
else:
model_arch = f"{compact_arch_name}{patch_size}"
return model_arch
def _make_dinov3_vit_model_url(
*,
patch_size: int = 16,
compact_arch_name: str = "vitb",
version: Optional[str] = None,
weights: Union[Weights, str] = Weights.LVD1689M,
hash: Optional[str] = None,
):
model_name = "dinov3"
model_arch = _make_dinov3_vit_model_arch(patch_size=patch_size, compact_arch_name=compact_arch_name)
version_suffix = f"_{version}" if version else ""
weights_name = weights.value.lower()
hash_suffix = f"-{hash}" if hash else ""
model_dir = f"{model_name}_{model_arch}"
model_filename = f"{model_name}_{model_arch}_pretrain_{weights_name}{version_suffix}{hash_suffix}.pth"
return os.path.join(DINOV3_BASE_URL, model_dir, model_filename)
def _make_dinov3_vit(
*,
img_size: int = 224,
patch_size: int = 16,
in_chans: int = 3,
compact_arch_name: str = "vitb",
pos_embed_rope_base: float = 100.0,
pos_embed_rope_min_period: float | None = None,
pos_embed_rope_max_period: float | None = None,
pos_embed_rope_normalize_coords: str = "separate",
pos_embed_rope_shift_coords: float | None = None,
pos_embed_rope_jitter_coords: float | None = None,
pos_embed_rope_rescale_coords: float | None = None,
pos_embed_rope_dtype: str = "fp32",
embed_dim: int = 768,
depth: int = 12,
num_heads: int = 12,
ffn_ratio: float = 4.0,
qkv_bias: bool = True,
drop_path_rate: float = 0.0,
layerscale_init: float | None = None,
norm_layer: str = "layernorm",
ffn_layer: str = "mlp",
ffn_bias: bool = True,
proj_bias: bool = True,
n_storage_tokens: int = 0,
mask_k_bias: bool = False,
pretrained: bool = True,
version: Optional[str] = None,
weights: Union[Weights, str] = Weights.LVD1689M,
hash: Optional[str] = None,
check_hash: bool = False,
**kwargs,
):
from ..models.vision_transformer import DinoVisionTransformer
vit_kwargs = dict(
img_size=img_size,
patch_size=patch_size,
in_chans=in_chans,
pos_embed_rope_base=pos_embed_rope_base,
pos_embed_rope_min_period=pos_embed_rope_min_period,
pos_embed_rope_max_period=pos_embed_rope_max_period,
pos_embed_rope_normalize_coords=pos_embed_rope_normalize_coords,
pos_embed_rope_shift_coords=pos_embed_rope_shift_coords,
pos_embed_rope_jitter_coords=pos_embed_rope_jitter_coords,
pos_embed_rope_rescale_coords=pos_embed_rope_rescale_coords,
pos_embed_rope_dtype=pos_embed_rope_dtype,
embed_dim=embed_dim,
depth=depth,
num_heads=num_heads,
ffn_ratio=ffn_ratio,
qkv_bias=qkv_bias,
drop_path_rate=drop_path_rate,
layerscale_init=layerscale_init,
norm_layer=norm_layer,
ffn_layer=ffn_layer,
ffn_bias=ffn_bias,
proj_bias=proj_bias,
n_storage_tokens=n_storage_tokens,
mask_k_bias=mask_k_bias,
)
vit_kwargs.update(**kwargs)
model = DinoVisionTransformer(**vit_kwargs)
if pretrained:
if type(weights) is Weights and weights not in {Weights.LVD1689M, Weights.SAT493M}:
raise ValueError(f"Unsupported weights for the backbone: {weights}")
elif type(weights) is Weights:
url = _make_dinov3_vit_model_url(
patch_size=patch_size,
compact_arch_name=compact_arch_name,
version=version,
weights=weights,
hash=hash,
)
else:
url = convert_path_or_url_to_url(weights)
state_dict = torch.hub.load_state_dict_from_url(url, map_location="cpu", check_hash=check_hash)
model.load_state_dict(state_dict, strict=True)
else:
model.init_weights()
return model
def _make_dinov3_convnext_model_url(
*,
compact_arch_name: str = "convnext_base",
weights: Union[Weights, str] = Weights.LVD1689M,
hash: Optional[str] = None,
):
model_name = "dinov3"
weights_name = weights.value.lower()
hash_suffix = f"-{hash}" if hash else ""
model_dir = f"{model_name}_{compact_arch_name}"
model_filename = f"{model_name}_{compact_arch_name}_pretrain_{weights_name}{hash_suffix}.pth"
return os.path.join(DINOV3_BASE_URL, model_dir, model_filename)
def _make_dinov3_convnext(
in_chans: int = 3,
depths: List[int] = [3, 3, 27, 3],
dims: List[int] = [128, 256, 512, 1024],
compact_arch_name: str = "convnext_base",
drop_path_rate: float = 0.0,
layer_scale_init_value: float = 1e-6,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
hash: Optional[str] = None,
**kwargs,
):
from ..models.convnext import ConvNeXt
model_kwargs = dict(
in_chans=in_chans,
depths=depths,
dims=dims,
drop_path_rate=drop_path_rate,
layer_scale_init_value=layer_scale_init_value,
)
model_kwargs.update(**kwargs)
model = ConvNeXt(**model_kwargs)
if pretrained:
if type(weights) is Weights and weights not in {Weights.LVD1689M, Weights.SAT493M}:
raise ValueError(f"Unsupported weights for the backbone: {weights}")
elif type(weights) is Weights:
url = _make_dinov3_convnext_model_url(
compact_arch_name=compact_arch_name,
weights=weights,
hash=hash,
)
else:
url = convert_path_or_url_to_url(weights)
state_dict = torch.hub.load_state_dict_from_url(url, map_location="cpu")
model.load_state_dict(state_dict, strict=True)
return model
def dinov3_vits16(
*,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
check_hash: bool = False,
**kwargs,
):
if "hash" not in kwargs:
kwargs["hash"] = "08c60483"
kwargs["version"] = None
return _make_dinov3_vit(
img_size=224,
patch_size=16,
in_chans=3,
pos_embed_rope_base=100,
pos_embed_rope_normalize_coords="separate",
pos_embed_rope_rescale_coords=2,
pos_embed_rope_dtype="fp32",
embed_dim=384,
depth=12,
num_heads=6,
ffn_ratio=4,
qkv_bias=True,
drop_path_rate=0.0,
layerscale_init=1.0e-05,
norm_layer="layernormbf16",
ffn_layer="mlp",
ffn_bias=True,
proj_bias=True,
n_storage_tokens=4,
mask_k_bias=True,
pretrained=pretrained,
weights=weights,
compact_arch_name="vits",
check_hash=check_hash,
**kwargs,
)
def dinov3_vits16plus(
*,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
check_hash: bool = False,
**kwargs,
):
if "hash" not in kwargs:
kwargs["hash"] = "4057cbaa"
kwargs["version"] = None
return _make_dinov3_vit(
img_size=224,
patch_size=16,
in_chans=3,
pos_embed_rope_base=100,
pos_embed_rope_normalize_coords="separate",
pos_embed_rope_rescale_coords=2,
pos_embed_rope_dtype="fp32",
embed_dim=384,
depth=12,
num_heads=6,
ffn_ratio=6,
qkv_bias=True,
drop_path_rate=0.0,
layerscale_init=1.0e-05,
norm_layer="layernormbf16",
ffn_layer="swiglu",
ffn_bias=True,
proj_bias=True,
n_storage_tokens=4,
mask_k_bias=True,
pretrained=pretrained,
weights=weights,
compact_arch_name="vitsplus",
check_hash=check_hash,
**kwargs,
)
def dinov3_vitb16(
*,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
check_hash: bool = False,
**kwargs,
):
if "hash" not in kwargs:
kwargs["hash"] = "73cec8be"
kwargs["version"] = None
return _make_dinov3_vit(
img_size=224,
patch_size=16,
in_chans=3,
pos_embed_rope_base=100,
pos_embed_rope_normalize_coords="separate",
pos_embed_rope_rescale_coords=2,
pos_embed_rope_dtype="fp32",
embed_dim=768,
depth=12,
num_heads=12,
ffn_ratio=4,
qkv_bias=True,
drop_path_rate=0.0,
layerscale_init=1.0e-05,
norm_layer="layernormbf16",
ffn_layer="mlp",
ffn_bias=True,
proj_bias=True,
n_storage_tokens=4,
mask_k_bias=True,
pretrained=pretrained,
weights=weights,
compact_arch_name="vitb",
check_hash=check_hash,
**kwargs,
)
def dinov3_vitl16(
*,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
check_hash: bool = False,
**kwargs,
):
untie_global_and_local_cls_norm = False
if weights == Weights.LVD1689M:
if "hash" not in kwargs:
kwargs["hash"] = "8aa4cbdd"
elif weights == Weights.SAT493M:
if "hash" not in kwargs:
kwargs["hash"] = "eadcf0ff"
untie_global_and_local_cls_norm = True
elif type(weights) is str:
import re
pattern = r"-(.{8}).pth"
matches = re.findall(pattern, weights)
if len(matches) != 1:
raise ValueError(f"Unexpected weights specification for the ViT-L backbone: {weights}")
hash = matches[0]
if hash == "eadcf0ff":
untie_global_and_local_cls_norm = True
kwargs["version"] = None
return _make_dinov3_vit(
img_size=224,
patch_size=16,
in_chans=3,
pos_embed_rope_base=100,
pos_embed_rope_normalize_coords="separate",
pos_embed_rope_rescale_coords=2,
pos_embed_rope_dtype="fp32",
embed_dim=1024,
depth=24,
num_heads=16,
ffn_ratio=4,
qkv_bias=True,
drop_path_rate=0.0,
layerscale_init=1.0e-05,
norm_layer="layernormbf16",
ffn_layer="mlp",
ffn_bias=True,
proj_bias=True,
n_storage_tokens=4,
mask_k_bias=True,
untie_global_and_local_cls_norm=untie_global_and_local_cls_norm,
pretrained=pretrained,
weights=weights,
compact_arch_name="vitl",
check_hash=check_hash,
**kwargs,
)
def dinov3_vitl16plus(
*,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
check_hash: bool = False,
**kwargs,
):
if "hash" not in kwargs:
kwargs["hash"] = "46503df0"
return _make_dinov3_vit(
img_size=224,
patch_size=16,
in_chans=3,
pos_embed_rope_base=100,
pos_embed_rope_normalize_coords="separate",
pos_embed_rope_rescale_coords=2,
pos_embed_rope_dtype="fp32",
embed_dim=1024,
depth=24,
num_heads=16,
ffn_ratio=6.0,
qkv_bias=True,
drop_path_rate=0.0,
layerscale_init=1.0e-05,
norm_layer="layernormbf16",
ffn_layer="swiglu",
ffn_bias=True,
proj_bias=True,
n_storage_tokens=4,
mask_k_bias=True,
pretrained=pretrained,
weights=weights,
compact_arch_name="vitlplus",
check_hash=check_hash,
**kwargs,
)
def dinov3_vith16plus(
*,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
check_hash: bool = False,
**kwargs,
):
if "hash" not in kwargs:
kwargs["hash"] = "7c1da9a5"
return _make_dinov3_vit(
img_size=224,
patch_size=16,
in_chans=3,
pos_embed_rope_base=100,
pos_embed_rope_normalize_coords="separate",
pos_embed_rope_rescale_coords=2,
pos_embed_rope_dtype="fp32",
embed_dim=1280,
depth=32,
num_heads=20,
ffn_ratio=6.0,
qkv_bias=True,
drop_path_rate=0.0,
layerscale_init=1.0e-05,
norm_layer="layernormbf16",
ffn_layer="swiglu",
ffn_bias=True,
proj_bias=True,
n_storage_tokens=4,
mask_k_bias=True,
pretrained=pretrained,
weights=weights,
compact_arch_name="vithplus",
check_hash=check_hash,
**kwargs,
)
def dinov3_vit7b16(
*,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
check_hash: bool = False,
**kwargs,
):
if weights == Weights.LVD1689M:
if "hash" not in kwargs:
kwargs["hash"] = "a955f4ea"
elif weights == Weights.SAT493M:
if "hash" not in kwargs:
kwargs["hash"] = "a6675841"
kwargs["version"] = None
untie_global_and_local_cls_norm = True
return _make_dinov3_vit(
img_size=224,
patch_size=16,
in_chans=3,
pos_embed_rope_base=100,
pos_embed_rope_normalize_coords="separate",
pos_embed_rope_rescale_coords=2,
pos_embed_rope_dtype="fp32",
embed_dim=4096,
depth=40,
num_heads=32,
ffn_ratio=3,
qkv_bias=False,
drop_path_rate=0.4,
layerscale_init=1.0e-05,
norm_layer="layernormbf16",
ffn_layer="swiglu64",
ffn_bias=True,
proj_bias=True,
n_storage_tokens=4,
mask_k_bias=True,
untie_global_and_local_cls_norm=untie_global_and_local_cls_norm,
pretrained=pretrained,
weights=weights,
compact_arch_name="vit7b",
check_hash=check_hash,
**kwargs,
)
def dinov3_convnext_tiny(
*,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
**kwargs,
):
_hash_convnext = "21b726bb"
if "hash" not in kwargs:
kwargs["hash"] = _hash_convnext
from ..models.convnext import convnext_sizes
size_dict = convnext_sizes["tiny"]
model = _make_dinov3_convnext(
in_chans=3,
depths=size_dict["depths"],
dims=size_dict["dims"],
compact_arch_name="convnext_tiny",
drop_path_rate=0,
layer_scale_init_value=1e-6,
pretrained=pretrained,
weights=weights,
**kwargs,
)
if not pretrained:
model.init_weights()
return model
def dinov3_convnext_small(
*,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
**kwargs,
):
_hash_convnext = "296db49d"
if "hash" not in kwargs:
kwargs["hash"] = _hash_convnext
from ..models.convnext import convnext_sizes
size_dict = convnext_sizes["small"]
model = _make_dinov3_convnext(
in_chans=3,
depths=size_dict["depths"],
dims=size_dict["dims"],
compact_arch_name="convnext_small",
drop_path_rate=0,
layer_scale_init_value=1e-6,
pretrained=pretrained,
weights=weights,
**kwargs,
)
if not pretrained:
model.init_weights()
return model
def dinov3_convnext_base(
*,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
**kwargs,
):
_hash_convnext = "801f2ba9"
if "hash" not in kwargs:
kwargs["hash"] = _hash_convnext
from ..models.convnext import convnext_sizes
size_dict = convnext_sizes["base"]
model = _make_dinov3_convnext(
in_chans=3,
depths=size_dict["depths"],
dims=size_dict["dims"],
compact_arch_name="convnext_base",
drop_path_rate=0,
layer_scale_init_value=1e-6,
pretrained=pretrained,
weights=weights,
**kwargs,
)
if not pretrained:
model.init_weights()
return model
def dinov3_convnext_large(
*,
pretrained: bool = True,
weights: Union[Weights, str] = Weights.LVD1689M,
**kwargs,
):
_hash_convnext = "61fa432d"
if "hash" not in kwargs:
kwargs["hash"] = _hash_convnext
from ..models.convnext import convnext_sizes
size_dict = convnext_sizes["large"]
model = _make_dinov3_convnext(
in_chans=3,
depths=size_dict["depths"],
dims=size_dict["dims"],
compact_arch_name="convnext_large",
drop_path_rate=0,
layer_scale_init_value=1e-6,
pretrained=pretrained,
weights=weights,
**kwargs,
)
if not pretrained:
model.init_weights()
return model异常检测在工业质量检测、医学图像分析和安防监控等现实应用中发挥着至关重要的作用[27]。传统的监督式异常检测方法通常需要为每个类别获取大量标注异常样本,这在大规模或动态环境中往往难以实现[4, 1, 3]。相比之下,无监督异常检测(UAD)仅使用正常样本进行训练即可识别图像中的异常,无需任何异常样本[30, 11, 13]。然而,获取大量正常图像进行训练仍然存在挑战。
传统的无监督异常检测方法依赖于对正常样本分布建模,这使其对领域偏移敏感,且每当引入新产品类别时都需要重新训练。这一局限性促使学界对零样本异常检测(ZSAD)日益关注,该技术旨在无需使用任何目标数据集样本的情况下,识别未见过的对象类别中的异常。实践中,ZSAD通过利用大规模预训练模型的可迁移性实现跨类别和跨领域的泛化。其中,预训练的视觉-语言模型(VLM)因其强大的跨模态对齐和泛化能力而备受青睐。特别是具有强大图文对齐能力的CLIP[28],已催生了一系列ZSAD方法[7, 44, 9, 29, 42]。这些方法通常通过计算图像块特征与文本提示词的余弦相似度来构建异常热力图,从而实现像素级的异常区域定位。
近期,在数十亿自然图像上预训练的视觉基础模型展现出卓越的跨任务迁移能力。迄今为止,大多数ZSAD方法仍依赖于CLIP[28]等视觉-语言模型,利用其图文对齐能力进行异常检测。相比之下,像DINOv3[35]这样的自监督视觉编码器在该任务中尚未得到充分探索。然而,将DINOv3直接应用于ZSAD面临两个关键挑战:(i)大规模预训练数据与异常检测任务间的领域偏差导致特征错位,限制了预训练表征在异常检测中的直接适用性;(ii)学习到的表征往往强调全局对象语义而忽略细微缺陷特征,导致异常被当作背景噪声而非异常区域。图1展示了原始DINOv3与我们提出的AD-DINOv3之间的差异。对于DINOv3,其相似度图谱存在两个主要问题:首先,在关注正常区域时(上图),响应错误地延伸至异常区域,表明存在将异常误判为正常组成部分的语义错位;其次,在关注异常区域时(下图),响应不仅包含正常区域的伪激活,且对比度不足,导致异常无法形成连贯显著的聚集区域。
在本研究中,我们提出AD-DINOv3——一个新颖的视觉-语言多模态框架,也是首个将DINOv3适配于ZSAD任务的方法。我们的方法采用DINOv3作为视觉骨干网络,同时提取图像块令牌和CLS令牌,实现统一的异常定位。为充分挖掘DINOv3的分层表征优势,我们引入具有轻量级适配器的多层级特征适配策略,保留互补的低层级与高层级特征线索。此外,我们设计了新颖的异常感知校准模块(AACM),显式引导CLS令牌关注异常区域而非通用前景语义。并行地,文本分支采用CLIP文本编码器表征正常与异常提示词,并通过适配器将其与目标领域对齐,与视觉分支进行跨模态对比学习。最终,通过比较视觉与文本表征实现异常检测:图像块令牌与提示词嵌入的相似度计算生成像素级异常热力图。如图1所示,相较于原始DINOv3,我们提出的AD-DINOv3产生更清晰的响应:正常区域更加紧凑且无伪激活,而异常区域更加显著且连贯。总体而言,正常与异常区域的区分度得到显著提升。我们的主要贡献包括:

图1:红点标注的图像块与所有其他图像块间的余弦相似度图谱。左图为真实标注,中间对应原始DINOv3结果,右侧为我们提出的AD-DINOv3结果。上下两行分别展示了对正常区域和异常区域的注意力分布。与原始DINOv3相比,AD-DINOv3有效减少了正常区域中的伪响应,更显著且连贯地突出异常区域,并增强正常与异常区域间的区分度。
计算机视觉领域的传统异常检测方法通常通过建模正常数据分布,并将偏离该分布的样本视为潜在异常。基于重构的方法(如自动编码器和变分自动编码器[17, 22])尝试重构输入图像,并利用重构误差识别异常区域。基于生成对抗网络(GAN)的模型通过对抗训练合成正常模式以检测偏差,进一步拓展了这一思路[1]。另一类研究聚焦于特征嵌入技术,单类支持向量机及其深度扩展方法旨在学习正常样本周围的紧凑决策边界[33, 32]。记忆增强方法(如MemAE)引入外部记忆模块以增强对正常数据的重构能力[14]。工业异常检测同样受益于大规模数据集上的表征学习——PatchCore[31]通过在预训练网络提取的特征上进行最近邻搜索策略,在MVTec AD数据集[4]上实现了最先进的性能。尽管这些方法取得了成功,但它们严重依赖充足且纯净的正常数据;在数据存在噪声或分布偏移的场景下,其泛化能力将受到显著限制。
并将其与描述正常类别的文本提示对齐,探索了CLIP在异常检测中的应用。后续研究通过引入提示学习改进这一思路:例如CoOp及其变体[43]提出可学习文本提示策略,该策略后被适配用于异常检测以提升特征对齐效果;类似地,AnomalyCLIP[39]集成多提示组合以更好地捕捉多样异常类型。近期研究通过引入校正机制解决CLIP在区分细微异常特征方面的局限性:如AFR-CLIP[40]提出从无状态到有状态的异常特征校正策略,将缺陷相关信息嵌入文本提示以改进异常定位;PromptAD[16]等方法采用领域自适应的提示调整来减小文本与视觉特征间的语义差距。
尽管这些进展凸显了CLIP在异常检测中的潜力,但在复杂背景下捕捉细粒度异常以及确保跨未知领域鲁棒性方面仍存挑战。这推动研究者探索更强的视觉骨干网络DINOv3,并开发专用于异常检测的自适应提示学习机制。
给定测试样本 I ∈ R^(H×W×3),零样本异常检测(ZSAD)的目标是生成像素级异常热力图 M ∈ [0, 1]^(H×W)。遵循现有ZSAD框架,我们使用辅助数据集 Da = {(I₁, G₁), ..., (Iₙ, Gₙ)}(其中 Gᵢ ∈ [0,1]^(H×W)),该数据集包含正常与异常样本{Iᵢ}ₙᵢ₌₁及其对应的真实标注掩码{Gᵢ}ₙᵢ₌₁。模型随后在不相交的测试数据集 Dt = {It₁, ..., Itm} 上进行评估,该测试集包含来自不同基准或领域的未见图像。关键的是,为保证零样本设置,辅助数据集与测试集必须满足非重叠条件,即 Da ∩ Dt = ∅。
我们采用DINOv3作为AD-DINOv3的视觉骨干网络。如图2所示,图像分支提取图像块令牌(patch tokens)和CLS令牌,这些特征通过轻量级适配器与异常感知校准模块(AACM)协同优化。AACM在掩码监督下显式引导CLS令牌聚焦于异常图像块区域,将其全局注意力从自然图像预训练中偏好的通用前景对象重定向至异常区域。并行地,文本分支对正常与异常提示词(如"一张[状态][类别]的照片")进行编码,并同样通过适配器优化以更好地与目标领域对齐。最终通过对比图像块令牌与提示词嵌入生成像素级异常热力图以实现异常定位。

在大规模自然图像上预训练的视觉-语言模型主要捕获高级语义信息(如对象类别或前景-背景分离)。虽然这种先验知识对许多识别任务有益,但并不适用于异常检测——该任务需要区分正常与异常区域之间的细粒度差异,这些差异可能仅体现在细微纹理或局部结构上。直接应用预训练特征会导致弱可分性:覆盖异常缺陷的图像块可能与正常区域高度相似,且文本嵌入未与异常语义显式对齐。
为解决此问题,我们在视觉和文本分支中引入轻量级适配器。每个适配器采用具有两个线性层和LeakyReLU激活函数的瓶颈结构多层感知机(MLP)实现,在保持强大预训练骨干网络冻结的同时,将表征重新校准至异常检测领域。该变换表示为Ada(·)。
形式化地,给定输入图像,视觉编码器生成图像块令牌{pᵢ}ₙᵢ₌₁和CLS令牌c,文本编码器则提供正常与异常提示词的嵌入表示t ∈ R^(d×2)。适配后的表征可表示为: {p̃ᵢ, c̃, t̃} = Ada({pᵢ, c, t}) (1)
随后我们将像素级定位构建为跨模态对比对齐任务。对于适配后的视觉图像块令牌p̃ᵢ,其与文本嵌入的相似度计算为: s = (p̃ᵢᵀt̃) / (‖p̃ᵢ‖‖t̃‖), p = σ(s) (2) 其中p ∈ R^(N×2)提供正常/异常概率,σ表示沿类别维度执行的softmax函数。在图像块级别,异常概率被重构成粗粒度热力图并通过双线性上采样恢复至原始图像分辨率,最终生成像素级异常检测图Ŝ ∈ R^(H×W)。
尽管CLS令牌(CLS token)本身设计用于聚合全局上下文信息,但由于其经过自然图像预训练,该令牌严重偏向于常见的 foreground 目标物体。这种偏差导致模型容易将异常区域与显著物体区域混淆,从而对细微缺陷的感知能力较差。为解决这一局限性,我们提出了异常感知校准模块(AACM),该模块引入了一种掩码引导的训练目标,显式地鼓励CLS令牌及其与 patch 令牌的交互更加关注异常区域。
具体来说,对于每个 patch *i*,其与校准后的CLS令牌 c˜ 之间的相似度计算如下:

该相似度分布 σ({s_i}) 随后通过 Focal Loss 和 Dice Loss 的组合与真实异常掩码 M 进行优化:

其中,Focal Loss [24] 侧重于难以分类的异常 patch,而 Dice Loss [25] 则提升了空间一致性,并缓解了正常像素与异常像素之间严重的类别不平衡问题。
通过这一优化目标,CLS令牌被逐步引导,将其全局注意力更多地分配给异常区域,从而进一步重塑与这些区域相关的 patch 令牌的特征空间。实际上,AACM 在不增加显著计算开销的情况下,增强了对全局与局部表征的异常感知能力。
训练阶段:通过对比跨模态异常检测图P与真实标注掩码M计算跨模态对齐损失。形式化表示为: L_CM = L_focal(P, M) + L_dice(P, M) (5) 完整训练目标由跨模态对齐损失与AACM正则化项共同构成: L = λ_CM * L_CM + λ_AACM * L_AACM (6) 其中λ_CM与λ_AACM为平衡权重系数。
推理阶段:由于测试时无法获取异常掩码,检测完全依赖跨模态相似度计算——通过图像块与文本的相似性匹配生成像素级异常热力图。由于AACM仅参与训练过程,我们的框架在测试阶段保持了与基线模型相同的计算效率。
数据集:为全面评估AD-DINOv3的有效性,我们在工业与医疗两大场景中进行实验。工业场景采用MVTec AD [5]、VisA [45]、BTAD [26]和MPDD [20]基准数据集,医疗场景则使用ISIC [10]、ColonDB [36]、ClinicDB [6]和TN3K [15]进行评估。由于VisA中的对象类别与其他数据集存在差异,我们采用跨数据集评估策略:在VisA上训练的模型在其他数据集上进行测试,反之使用MVTec AD训练的模型进行VisA测试。
评估指标:遵循零样本异常检测领域惯例,我们采用多种指标进行异常定位评估。具体而言,像素级异常定位通过受试者工作特征曲线下面积(AUROC)衡量,该指标综合反映判别能力及精确率-召回率平衡性。此外,为与现有ZSAD方法公平对比,我们引入最大F1分数(F1)。最终通过计算所有领域的平均性能提供整体评估。
实现细节:实验采用Meta AI发布的ViT-L/16架构的预训练DINOv3作为默认图像编码器,文本编码器使用预训练CLIP(OpenAI)生成文本嵌入。所有输入图像统一调整为512×512分辨率。DINOv3骨干网络包含24个Transformer层,我们将其划分为四个阶段,分别从第6、12、18和24层提取图像块嵌入。模型训练周期为10轮,批量大小为64,使用Adam优化器,初始学习率设为1×10⁻⁴。所有实验均在单张NVIDIA RTX A6000 GPU(48GB)上完成。
对比方法:为全面对比,我们将AD-DINOv3与两种典型范式的代表性方法进行基准测试:无需训练的方法与依赖辅助数据集的方法。第一类包含开创性CLIP框架WinCLIP [19](无需任何训练数据);第二类包含APRIL-GAN [8]、AdaCLIP [7]和AnomalyCLIP [44](使用外部辅助数据训练后评估未见对象类别)。
工业图像基准测试对比 如表1所示,AD-DINOv3在工业基准测试中实现了最先进的性能。在VisA数据集上,我们的方法达到95.6%的AUROC和37.7%的F1分数,显著超越WinCLIP(79.6%/14.8%)和AnomalyCLIP(95.4%/28.3%)等现有方法。在BTAD和MPDD数据集上,本方法分别取得93.5%和96.2%的AUROC分数, consistently outperforming AdaCLIP and APRILGAN。在广泛使用的MVTec AD数据集上,AD-DINOv3以91.6%的AUROC和50.1%的F1分数创下新纪录。
在所有工业数据集的平均性能中,AD-DINOv3达到94.2%的AUROC和44.6%的F1分数,明显超越所有竞争方法。这证明了融合DINOv3判别性表征的优势——有效增强了正常与异常特征的可分离性。

表1:零样本异常检测(ZSAD)方法在工业与医疗数据集上的像素级性能对比。最佳性能以粗体标注,次佳成绩添加下划线标示。

图3:零样本异常检测(ZSAD)方法可视化结果。(a)与(b)分别展示工业领域和医疗领域的异常定位效果。
图3(a)的定性对比进一步验证了这一结论。竞争方法(尤其是WinCLIP和APRILGAN)常产生模糊或含噪声的热力图,无法准确勾勒缺陷区域。相比之下,AD-DINOv3生成清晰精确的异常热力图,能有效突出金属表面划痕、PCB板结构缺陷等异常组件,同时抑制无关背景噪声。
医疗图像基准测试对比 我们在医疗数据集上进一步评估AD-DINOv3处理细粒度视觉细微异常的能力。如表1总结,本方法相较现有工作实现持续提升:在ClinicDB上达到90.4%的AUROC和54.3%的F1分数,显著优于AnomalyCLIP(82.9%/42.1%)和AdaCLIP(82.8%/40.9%);在ISIC上取得89.0%的AUROC和72.1%的F1分数,在保持均衡定位精度的同时与AdaCLIP竞争力相当。在ColonDB和TN3K数据集上也观察到类似提升。
在四个医疗数据集的平均性能中,AD-DINOv3以84.5%的AUROC和51.5%的F1分数确立了最新性能标杆,凸显了本框架在捕捉医疗图像中典型小尺寸、高模糊度异常方面的鲁棒性。
图3(b)的定性结果进一步说明这一优势:竞争方法常将正常组织误判为异常或无法定位细微病变(尤其在皮肤镜和内镜图像中);反之,AD-DINOv3生成更清晰、判别性更强的热力图,能成功识别皮肤与结肠图像中的病变区域,并精确定位甲状腺扫描中的异常模式。这些结果印证了本方法在高精度高灵敏度要求的现实医疗应用中的潜力。
如表2所示,我们在MVTec AD数据集上逐步解构AD-DINOv3以验证各组件贡献。基线方法直接对原始DINOv3图像块令牌进行L2归一化,沿通道维度求平均后上采样至图像尺寸作为异常热力图,获得76.20%的AUROC和20.49%的F1分数,证实单一自监督特征无法可靠区分正常与异常区域。

跨模态对比学习(CMCL)消融研究:如表2第2行所示,引入显式对齐视觉与文本特征的CMCL模块带来显著性能提升——AUROC提升至90.98%(+14.78%),F1分数增至47.00%(+26.51%)。该进步表明跨模态对齐有效缩小了图像特征与语义提示间的差距:通过强制多模态一致性,CMCL校准了嵌入空间,使正常与异常区域更具可分性,从而优化像素级决策边界并增强整体判别能力。这些结果证实利用文本分支的语义线索对引导视觉表征进行异常感知特征学习至关重要。
异常感知校准模块(AACM)消融研究:如表2第3行所示,添加AACM模块进一步将AUROC提升至91.60%,F1分数提高至50.13%。该增益证明显式引导全局CLS令牌关注缺陷区域能有效增强模型性能:通过将注意力从通用前景语义重定向至异常区域,AACM校准了表征空间并使模型聚焦于细微但关键的异常线索,从而产生更精确的异常热力图和更可靠的图像级决策。
多层级特征消融研究:为探究视觉骨干网络不同层级特征的影响,我们对比了单层级与多层级设置。单层级变体仅使用最后一层输出与文本嵌入计算相似度进行异常检测;多层级变体则聚合第6、12、18和24层的特征,并将四个阶段的异常热力图取平均获得最终预测。
如表3所示,采用多层级特征在所有评估指标上均带来持续改进。相较于单层级基线,多层级策略实现AUROC提升1.22%,F1分数增加1.89%,证明中间层特征包含互补信息,有助于同时捕获低层外观特征与高层语义上下文。这表明利用分层表征能增强模型区分异常与正常区域的能力。
本研究提出了AD-DINOv3——首个将DINOv3适配于零样本异常检测(ZSAD)任务的框架。我们的方法集成轻量级适配器与新颖的异常感知校准模块(AACM),通过优化图像块令牌和CLS令牌显式引导模型聚焦异常区域而非通用前景语义。在8个工业及医疗基准测试中,AD-DINOv3匹配或超越了现有最先进方法,彰显了其在ZSAD任务中的鲁棒性与广泛适用性。我们进一步发现:由于类内差异,DINOv3表征会将正常区域分割为多个簇,而异常区域则无法形成显著簇结构。总体而言,本研究证明DINOv3能产生比CLIP更具判别力的视觉特征,为ZSAD提供了强大且可泛化的解决方案。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。