



论文:https://arxiv.org/pdf/2607.01949
摘要:在现代高通量工业生产线上,产品配置和视觉特征频繁变化,因此为每个新场景收集和标注数据变得不切实际。这种动态环境使得零样本异常检测特别适用,因为它能够在无需对特定目标样本进行训练的情况下实现缺陷检测。尽管近期的ZSAD方法显示出有前景的结果,但它们计算密集,不适合部署在资源受限的设备上。我们提出了LiZAD:一个专为边缘设备实时ZSAD设计的轻量级框架。所提出的方法将DINOv3密集且具有空间感知能力的视觉特征(对于精确的像素级定位至关重要)与MobileCLIP2的高计算效率文本嵌入相结合。这些特征随后通过低内存可训练的投影头映射到一个共享的潜在空间。与六个最先进的ZSAD模型相比,LiZAD实现了平均内存减少61.5%,参数减少74.6%,以及延迟加速3.02倍。尽管计算和内存成本大幅降低,我们的方法仍保持了有竞争力的异常检测性能,在VisA、BTAD、MPDD和MVTec-AD数据集上,相对于最佳最先进模型,平均P-AUROC仅下降了6.4%。最后,它已成功部署在NVIDIA Jetson NX和Jetson AGX边缘设备上,并在维罗纳大学工业计算机工程实验室的真实生产线上进行了测试。
代码:https://github.com/intelligolabs/LiZAD

在工业制造中,实时检测缺陷产品对于确保高质量标准、减少浪费和优化生产效率至关重要[1]。及早发现缺陷产品的能力可以防止代价高昂的召回并提高客户满意度[2]。传统的监督式异常检测在此任务中表现出了强劲的性能[3]。然而,其有效性高度依赖于可用的正负标注样本的数量和多样性。此外,为每个新引入的产品获取此类数据成本高昂、耗时,且在实践中通常不可行[4], [5]。因此,研究人员将重点转向了无监督异常检测,即模型仅使用负样本进行训练,并学习识别与所学分布的偏差[6], [7]。尽管这种方法减轻了对缺陷标注样本的需求,但它仍然需要操作员精心策划训练数据集,以确保不存在异常样本[2]。
这些局限性促使人们对零样本异常检测的兴趣日益增长,其目标是在不使用任何这些类别的训练图像的情况下,检测先前未见过的类别中的异常[8]。尽管前景广阔,现有的ZSAD方法在实际生产环境中仍然面临显著的实际局限性。首先,许多当前方法建立在大型视觉-语言骨干网络和额外的重型组件之上,导致对计算和内存的需求很高,如图1所示。因此,此类系统难以部署在资源受限的设备、边缘平台或成本敏感的检测线上。其次,它们的推理速度通常对于现代工业工作流来说太慢,因为检测必须跟上高通量生产的步伐[9]。因此,除了检测精度之外,可部署性和效率对于此类工业异常检测系统同样是重要的要求。
为解决当前模型的局限性,我们提出了LiZAD,一个专为资源受限边缘设备上的实时ZSAD设计的轻量级框架。它结合了DINOv3[10]的特征(为细粒度异常定位提供强大的空间表示)和MobileCLIP2[11]的文本嵌入(实现低成本的语义引导)。LiZAD实现了最先进的计算效率,将平均推理延迟降低了69.9%。此外,它通过将内存需求降低61.5%(至1.01 GB)并将总参数量减少74.6%(至97.44 M),降低了边缘部署的资源障碍。值得注意的是,这些效率提升仅以最小的定位性能为代价;LiZAD保持了极具竞争力的88.05%的平均像素级AUROC,与绝对性能最佳的基线相比仅下降了6.4个百分点。我们将LiZAD部署在NVIDIA Jetson NX和Jetson AGX边缘设备上,并在MMS数据集[12]以及维罗纳大学工业计算机工程实验室真实生产线的产品上进行了定性测试[13]。
零样本异常检测旨在无需访问目标域训练数据的情况下,检测先前未见过的物体类别中的异常。设 D_a 和 D_e 分别表示辅助训练集和评估数据集,C_a 和 C_e 表示它们对应的类别集合。在零样本设置中,训练类别和评估类别是不相交的,即 C_a ∩ C_e = ∅。在此设置下,在 D_a 上训练的模型必须泛化到 D_e 中的图像,而无需在训练期间观察其类别。因此,给定一幅评估图像 x ∈ D_e,LiZAD 通过异常图 ˆY ∈ {0,1}^{H×W} 在像素级预测异常,其中较高的值表示异常的可能性更高。
现有的 ZSAD 方法通常基于 CLIP 风格模型。尽管这类模型在全局图像-文本匹配方面是有效的,但其视觉表示主要针对语义对齐进行了优化,通常不太适用于细粒度的异常定位。然而,在工业检测中,缺陷通常是微妙的、空间局部的,并且难以仅从全局表示中识别。
为解决此问题,我们采用 DINOv3 作为视觉骨干网络,如图2所示。为确保我们的框架在计算上保持高效而不牺牲表示能力,我们采用了紧凑的 ViT-S/16 变体,该变体在其自监督预训练期间使用 Gram 锚定正则化。这明确地保留了强大的密集图像块级表示并防止特征退化,使其非常适合细粒度的异常定位。

对于文本分支,我们采用 MobileCLIP2,这是一种专门设计用于在边缘硬件上通过高度优化的卷积-Transformer 设计实现快速低延迟执行的架构。此外,它通过多模态强化训练克服了轻量级网络典型的表现下降,充当来自大规模教师模型的复杂知识蒸馏过程。这确保了我们的框架在推理时以最小的内存占用和计算开销提取高度表达性的文本嵌入。
在推理期间,我们使用与训练期间相同的目标物体类别的正常和异常提示模板。输入图像首先被编码为图像块级特征,然后投影到共享嵌入空间中。通过投影后的视觉块特征与正常/异常文本嵌入之间的余弦相似度计算粗略的图像块级异常图。最后,将图上采样到输入分辨率以获得最终的密集异常热图。
给定一幅输入图像,LiZAD 使用冻结的 DINOv3 编码器提取全局视觉 token 和图像块级嵌入,而正常和异常文本提示则由冻结的 MobileCLIP2 文本编码器编码。
形式上,设 f_v 和 f_t 分别表示冻结的 DINOv3 图像编码器和冻结的 MobileCLIP2 文本编码器。对于输入图像 x,视觉编码器产生全局 token c ∈ R^{D_v} 和一组图像块嵌入 {p_i}_{i=1}^{N},其中 p_i ∈ R^{D_v},N 是空间图像块的数量。对于正常和异常文本提示 q_n 和 q_a,文本编码器输出文本嵌入 t_n ∈ R^{D_t} 和 t_a ∈ R^{D_t}。
由于一般情况下 D_v ≠ D_t,且两个编码器未联合训练,直接比较相似性是没有意义的。因此,我们学习轻量级线性投影,将全局 token、图像块嵌入和文本嵌入映射到一个公共的 D 维空间:

其中 proj_c(·) 和 proj_p(·) 是可学习的投影头。
遵循标准的文本引导异常检测协议 [29], [31], [35],我们使用模板“an image of a {state} {object}”构建正常和异常提示,其中 state 是 normal 或 abnormal,object 表示物体类别。文本嵌入通过以下方式获得:

并投影到共享空间中:

其中 proj_{t,n} 和 proj_{t,a}(·) 是可学习的线性投影层。这些头是内存高效的,并且仅引入了少量可训练参数,从而保持了整体框架的效率。
为了衡量图像-文本对齐程度,我们使用余弦相似度。设 sim(·,·) 表示余弦相似度函数。对于每个投影后的图像块嵌入 ˆp_i,我们计算其与正常和异常文本嵌入的相似度:

然后,我们使用 softmax 函数将这两个相似度分数转换为概率分布,并将图像块级异常分数定义为异常类别的概率:

其中 Softmax(·) 表示 softmax 操作,A_i^p 是分配给异常类别的概率。
遵循先前的工作 [29], [31],为了稳定共享嵌入空间并校准正常-异常文本方向(超越稀疏的图像块级监督),我们引入了一个基于投影后全局 token 的全局上下文正则化器:

其中 A_c 是分配给异常类别的概率。
通过堆叠所有图像块级异常分数,我们获得一个粗略的异常图:

其中 H_p 和 W_p 表示图像块网格的空间维度。为了恢复输入分辨率的密集异常热图,我们使用双线性插值对粗略图进行上采样:

其中 Ã^p ∈ R^{H×W} 是最终的像素级异常图。
在训练阶段,像素级异常图 Ã^p 使用真实掩码 M ∈ {0,1}^{H×W} 进行监督,全局上下文正则化器 A_c 使用类别标签 C ∈ {0,1} 进行监督。因此,我们使用焦点损失、Dice 损失和二元交叉熵损失的组合来优化模型:

其中 α 和 β 是平衡系数。在训练期间,仅优化轻量级投影头,而 DINOv3 图像编码器和 MobileCLIP2 文本编码器均保持冻结。
数据集与竞争方法。 我们在 MVTec-AD [36]、BTAD [37]、MPDD [38] 和 VisA [39] 上评估了 LiZAD,并与六个最先进的模型进行了对比。我们遵循 [8]、[30]、[31] 中采用的协议,在 VisA 上训练模型,并在其他数据集上进行测试。重要的是,这些数据集中的物体类别与 VisA 中的不同。相比之下,对于 VisA 上的评估,模型是在 MVTec-AD 上训练的。我们还在两种实际环境中评估了 LiZAD 的性能:MMS 数据集 [12] 和维罗纳大学 ICE 实验室的真实生产线 [13]。
训练细节。 我们使用配备 64 GB 系统内存的单张 NVIDIA RTX 3090 GPU 训练模型 100 个 epoch。训练设置使用 64 的批量大小和 1×10⁻⁴ 的学习率。我们采用 AdamW 优化器,权重衰减为 1×10⁻² 以提高泛化能力。我们从 DINOv3 中提取多级特征,具体来自第 3、5、7 和 11 个块。所有图像已调整为 512×512。每个投影头是一个包含 256 个神经元的线性层。我们设置 α = 0.9 和 β = 0.1。
LiZAD 的主要优势在于其效率。如表 I 所示,在所有对比方法中,LiZAD 实现了最低的延迟、内存使用量和总参数量。这些值是在用于训练的同一台服务器上,使用 [40] 中的代码获得的。其他细节,如 CUDA 优化设置、预热策略和预处理,可在 GitHub 仓库中获取。与最慢的方法相比,LiZAD 将延迟降低了 76.7%;与内存占用最大的方法相比,它将内存消耗降低了 69.4%;与最重的模型相比,它将总参数量降低了 88.8%。即使与强大的近期基线相比,增益仍然显著;与 AdaCLIP 相比,LiZAD 将延迟降低了 53.9%,内存使用量降低了 52.1%,总参数降低了 77.8%;与 AnomalyCLIP 相比,它将总参数降低了 80.8%。

表 II 报告了 LiZAD 与近期 ZSAD 方法的定量比较。LiZAD 在多个数据集上保持接近最强先前方法的性能,在 VisA 上仅比最佳报告结果低 1.3%,在 MVTec-AD 上低 5.2%。在 MPDD 上,差距也保持在 10% 以下。在四个数据集上平均,LiZAD 保留了对比方法所实现的最佳像素级性能的 88% 以上,表明所提出的轻量级设计尽管大幅降低了复杂度,但仍保持了强大的定位能力。

图 3 展示了 LiZAD 在 MVTec-AD 和 VisA 的代表性类别上的定性结果。在不同的纹理、形状和缺陷模式下,LiZAD 产生了清晰且集中的响应,表明在多样化的工业环境中具有鲁棒的定位行为。

LiZAD 已成功部署在 NVIDIA Jetson NX 和 Jetson AGX 设备上,并在维罗纳大学 ICE 实验室的真实生产线上进行了测试,在 Jetson NX 上实现了每样本 754.6 毫秒的平均推理延迟,功耗为 14.8 W,而 Jetson AGX 在 28.5 W 的功耗下提供了 554.4 毫秒的更低延迟。图 4 展示了代表性示例。LiZAD 还在 MMS 数据集上进行了评估,该数据集因其极具挑战性的真实世界检测基准而被选中。图 4 的最后一行展示了定性结果。

我们提出了 LiZAD,一个专为实际工业部署设计的轻量级 ZSAD 框架。通过将 DINOv3 和 MobileCLIP2 与简单的投影头相结合,所提出的架构在显著降低延迟、内存使用量和模型复杂度的同时,实现了有竞争力的异常检测性能。在公共基准和真实生产线上的实验表明,LiZAD 在有效性和可部署性之间提供了有利的权衡。
conda create -n LiZAD python=3.12
conda activate LiZAD
pip install -r requirements.txtgit clone https://github.com/apple/ml-mobileclip.git
cd ml-mobileclip
pip install .

更新 `datasets/__init__.py` 文件中的 `base_dir` 变量,使其指向你的数据集根目录。

import os
import csv
import time
from argparse import ArgumentParser
import cv2
import numpy as np
import torch
from PIL import Image
from tqdm import tqdm
from utils.args import Args
from datasets.constants import DatasetConstants
from utils.utils import generate_text_embeddings
from utils.transformations import get_transforms
from backbones.encoders import ImageEncoder
from model.model import ZSADModel
IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.bmp', '.tif')
def collect_images(input_path):
if os.path.isfile(input_path):
return [input_path]
if os.path.isdir(input_path):
img_paths = []
for root, dirs, files in os.walk(input_path):
dirs[:] = [d for d in dirs if d.lower() != 'ground_truth'] # skip gt masks in dataset layouts
for f in files:
if f.lower().endswith(IMAGE_EXTENSIONS):
img_paths.append(os.path.join(root, f))
return sorted(img_paths)
raise FileNotFoundError(f"Input path does not exist: {input_path}")
def infer_cls_name(img_path, class_names):
parts = [p.lower() for p in os.path.normpath(img_path).split(os.sep)]
for cls in class_names:
if cls.lower() in parts:
return cls
return None
def load_image(img_path):
if img_path.lower().endswith('.tif'):
img = cv2.imread(img_path)
img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
else:
img = Image.open(img_path).convert('RGB')
return img
def save_heatmap_overlay(img_path, anomaly_map, save_path):
img = cv2.imread(img_path)
h, w = img.shape[:2]
heatmap = cv2.resize(anomaly_map, (w, h), interpolation=cv2.INTER_LINEAR)
heatmap = (heatmap - heatmap.min()) / (heatmap.max() - heatmap.min() + 1e-8)
heatmap = np.uint8(255 * heatmap)
heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET)
overlay = cv2.addWeighted(img, 0.5, heatmap, 0.5, 0)
os.makedirs(os.path.dirname(save_path), exist_ok=True)
cv2.imwrite(save_path, overlay)
def predict_batch(batch, model, image_encoder, text_embeddings, device):
imgs = torch.stack([b['img'] for b in batch], dim=0)
normal_batch = torch.stack([text_embeddings['normal'][b['cls_name']] for b in batch], dim=0)
abnormal_batch = torch.stack([text_embeddings['abnormal'][b['cls_name']] for b in batch], dim=0)
text_embeddings_dict = {
'normal': normal_batch.to(device),
'abnormal': abnormal_batch.to(device)
}
cls, patches = image_encoder(imgs)
text_guided_anomaly_scores, global_context_regularizer_score = model(text_embeddings_dict, [cls, patches])
pixel_anomaly_maps = text_guided_anomaly_scores[:, 1, :, :].cpu().detach().numpy().astype(np.float32)
image_scores = torch.softmax(global_context_regularizer_score, dim=1)[:, 1].cpu().detach().numpy()
return pixel_anomaly_maps, image_scores
def predict(cli_args):
dataset_constants = DatasetConstants('./', cli_args.dataset_name)
class_names = dataset_constants.get_class_names()
img_paths = collect_images(cli_args.input)
if len(img_paths) == 0:
raise FileNotFoundError(f"No images found in: {cli_args.input}")
# Resolve class name for every image before loading any model
cls_names = []
for img_path in img_paths:
cls_name = cli_args.cls_name or infer_cls_name(img_path, class_names)
if cls_name is None:
raise ValueError(
f"Could not infer class name from path: {img_path}\n"
f"Please specify it explicitly with --cls_name (one of: {class_names})"
)
cls_names.append(cls_name)
args = Args(
vision_model_id="facebook/dinov3-vits16-pretrain-lvd1689m",
model_id="MobileCLIP2-S0",
vision_layers=[3, 5, 7, 11],
text_dim=512,
vision_dim=384,
base_dir="./",
batch_size=cli_args.batch_size,
img_size=518,
lr=1e-4,
out_dim=256,
device=cli_args.device,
)
print(f"Loading text embeddings for dataset: {cli_args.dataset_name}")
text_embeddings = generate_text_embeddings(args, dataset_constants)
transform_img, _ = get_transforms(args.img_size)
model = ZSADModel(args).to(args.device)
print(f"Loading model: {cli_args.model_path}")
checkpoint = torch.load(cli_args.model_path, map_location=args.device)
if 'model_state_dict' in checkpoint:
model.load_state_dict(checkpoint['model_state_dict'])
else:
model.load_state_dict(checkpoint)
model.eval()
image_encoder = ImageEncoder(args.vision_model_id, args.vision_layers, device=args.device)
os.makedirs(cli_args.output_dir, exist_ok=True)
input_is_dir = os.path.isdir(cli_args.input)
results = []
with torch.no_grad():
for start in tqdm(range(0, len(img_paths), args.batch_size), desc="Predicting"):
batch_paths = img_paths[start:start + args.batch_size]
batch = []
for img_path, cls_name in zip(batch_paths, cls_names[start:start + args.batch_size]):
batch.append({
'img': transform_img(load_image(img_path)),
'cls_name': cls_name,
'img_path': img_path,
})
if args.device.startswith('cuda'):
torch.cuda.synchronize()
t0 = time.perf_counter()
pixel_anomaly_maps, image_scores = predict_batch(batch, model, image_encoder, text_embeddings, args.device)
if args.device.startswith('cuda'):
torch.cuda.synchronize()
per_image_time_ms = (time.perf_counter() - t0) / len(batch) * 1000
for item, anomaly_map, image_score in zip(batch, pixel_anomaly_maps, image_scores):
if input_is_dir:
rel = os.path.relpath(item['img_path'], cli_args.input)
else:
rel = os.path.basename(item['img_path'])
save_path = os.path.join(cli_args.output_dir, os.path.splitext(rel)[0] + '_heatmap.png')
save_heatmap_overlay(item['img_path'], anomaly_map, save_path)
results.append({
'img_path': item['img_path'],
'cls_name': item['cls_name'],
'anomaly_score': float(image_score),
'max_pixel_score': float(anomaly_map.max()),
'inference_time_ms': per_image_time_ms,
'heatmap_path': save_path,
})
csv_path = os.path.join(cli_args.output_dir, 'results.csv')
with open(csv_path, 'w', newline='') as f:
writer = csv.DictWriter(f, fieldnames=['img_path', 'cls_name', 'anomaly_score', 'max_pixel_score', 'inference_time_ms', 'heatmap_path'])
writer.writeheader()
writer.writerows(results)
for r in results:
print(f"{r['img_path']} cls={r['cls_name']} anomaly_score={r['anomaly_score']:.4f} max_pixel_score={r['max_pixel_score']:.4f} inference_time={r['inference_time_ms']:.1f}ms")
avg_time_ms = sum(r['inference_time_ms'] for r in results) / len(results)
print(f"\nDone. {len(results)} image(s) predicted, avg inference time: {avg_time_ms:.1f} ms/image. Heatmaps and results.csv saved to: {cli_args.output_dir}")



原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。