资讯动态

工业成像异常检测实战:基于Jupyter Notebook的像素级定位与MVTec数据全流程

发布时间:2026/10/23 3:11:02 来源:尧图企业网站定制
简介这份硕士项目资源聚焦工业成像中的异常检测面向具备一定机器学习基础、希望将算法落地到质量控制与设备故障预防场景的学生和工程师。内容围绕数据预处理、特征提取、模型选择与训练、异常评分与阈值设定、实验评估及可解释性等环节展开并涉及半导体制造、机械部件检测、纺织品缺陷识别等案例帮助读者建立从数据处理到实时系统集成的完整认知。资源包共13个文件以txt说明、md文档、py脚本和ipynb笔记本为主另有gitignore等配置项整体约71KB涵盖PADIM、Patch-SVDD、SPADE、EGBAD、RIAD等方法模块及GDXRay、MVTec数据集说明。目前已有1782人学习下载适合作为工业异常检测方向的入门参考与项目复现素材。1. 工业成像异常检测的 Jupyter Notebook 实战从 MVTec 数据到像素级定位产线上一个表面划痕只有几个像素宽传统阈值分割要么漏检要么过杀这是工业成像异常检测最典型的场景。这份硕士项目资源用 Jupyter Notebook 把整条链路串了起来数据加载、特征提取、异常图生成、阈值调优、可视化评估每个环节都能单独跑、单独改。它解决的不是有没有异常的二分类问题而是异常在哪、有多大、置信度多少的像素级定位问题。适合正在做工业视觉课题的研究生、需要快速验证异常检测方案的算法工程师以及想从分类模型转向密度估计方法的人。Notebook 的好处是每一步的中间结果都能直接看到热力图调参不用靠猜。2. 异常检测的三种技术路线为什么选重建加密度估计2.1 从分类到密度估计的范式转换工业异常检测和常规图像分类最大的区别在于缺陷样本太少甚至没有。你不可能为每种划痕、凹坑、色差都收集几百张标注图。所以主流方案都绕开了监督分类转向无监督或半监督的密度估计。常见做法有三类基于重建的自编码器AE/VAE、基于特征嵌入的 PatchCore/PaDiM、基于归一化流的 FastFlow。这份 Notebook 走的是重建加密度估计的混合路线用预训练特征做嵌入再对正常样本的特征分布建模推理时算马氏距离或余弦距离作为异常分数。为什么不用纯重建因为自编码器有个玄学问题它有时能把缺陷也重建得很好导致异常分数偏低。特征嵌入方法对纹理类缺陷更敏感但对结构性异常比如整块区域缺失反而不如重建方法直观。混合路线的好处是两条腿走路Notebook 里可以分别跑两组分数再做融合。2.2 特征提取骨干的选择逻辑Notebook 默认用的是在 ImageNet 上预训练的 WideResNet50取 layer2 和 layer3 的特征图做多尺度拼接。为什么不用 ResNet18因为工业图像的缺陷尺度差异大浅层特征分辨率高但语义弱深层语义强但空间分辨率低。layer2 输出 28×28layer3 输出 14×14上采样对齐后拼接能同时捕捉细划痕和大块污渍。如果你手头的 GPU 显存小于 8GB可以把骨干换成 ResNet18特征维度从 1536 降到 512显存占用大约降 60%但小缺陷的召回率会掉 3 到 5 个百分点。这个取舍在 Notebook 的 config 单元格里改一行就行。# config 单元格骨干网络与特征层选择 BACKBONE wide_resnet50_2 # 可选 resnet18 FEATURE_LAYERS [layer2, layer3] # 多尺度特征来源 INPUT_SIZE (256, 256) # 工业图像常用尺寸太大显存吃不消 BATCH_SIZE 8 # 显存 8GB 以下建议降到 4逻辑说明FEATURE_LAYERS 决定从骨干的哪些阶段取特征图。layer2 的感受野较小对细粒度纹理敏感layer3 感受野大对区域级异常响应强。INPUT_SIZE 设为 256 是工业场景的折中MVTec 原始图像是 900×900 左右直接缩到 224 会丢失小缺陷256 是精度和显存的平衡点。2.3 异常分数计算与阈值确定Notebook 里异常分数的计算分两步先对每个像素位置算特征向量与正常分布的马氏距离得到异常热力图再对热力图做高斯平滑取最大值作为图像级分数。阈值不是拍脑袋定的而是用正常验证集的分数分布算出来的。常见做法是取正常样本分数的 99 分位数作为阈值或者用 F1 最大化在验证集上搜。# 阈值搜索在正常验证集上确定图像级判定门限 import numpy as np from sklearn.metrics import f1_score normal_scores [] # 正常验证集图像级分数 for batch in normal_val_loader: scores model.predict_anomaly_score(batch) normal_scores.extend(scores) # 方法一99 分位数 threshold_p99 np.percentile(normal_scores, 99) # 方法二在带标注的验证集上最大化 F1 best_th, best_f1 0, 0 for th in np.linspace(min(normal_scores), max(normal_scores), 200): preds (val_scores th).astype(int) f1 f1_score(val_labels, preds) if f1 best_f1: best_f1, best_th f1, th参数说明percentile 的 99 意味着允许 1% 的正常样本被误判为异常这个值根据产线容忍度调整。如果过杀率要求极低可以提到 99.5如果漏检代价更高降到 98。F1 搜索适合有少量标注缺陷样本的情况但要注意验证集不能和测试集重叠否则阈值会过拟合。3. Notebook 逐单元格拆解数据加载到推理全流程3.1 数据目录结构与加载器配置这份 Notebook 默认适配 MVTec AD 的目录格式但代码里做了抽象换成自己的数据只需要改根路径和类别名。标准结构是每个类别一个文件夹里面分 train/good、test/good、test/defect_type 三层。加载器用 torchvision 的 ImageFolder 做基础但重写了getitem来同时返回图像和掩码。# 数据加载适配 MVTec 目录结构同时返回图像与像素级掩码 import os from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class IndustrialADDataset(Dataset): def __init__(self, root, category, splittrain, transformNone): self.root os.path.join(root, category, split) self.transform transform or T.Compose([ T.Resize((256, 256)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) self.samples [] for defect_type in os.listdir(self.root): defect_dir os.path.join(self.root, defect_type) if not os.path.isdir(defect_dir): continue for img_name in os.listdir(defect_dir): if img_name.endswith((.png, .jpg, .bmp)): img_path os.path.join(defect_dir, img_name) # 掩码路径MVTec 中 defect_type 对应的 _mask.png mask_path img_path.replace(.png, _mask.png) self.samples.append((img_path, mask_path, defect_type)) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, mask_path, defect_type self.samples[idx] img Image.open(img_path).convert(RGB) img self.transform(img) if os.path.exists(mask_path): mask Image.open(mask_path).convert(L) mask T.Resize((256, 256))(mask) mask (T.ToTensor()(mask) 0.5).float() else: mask torch.zeros(1, 256, 256) return img, mask, defect_type逻辑说明getitem返回三元组而不是二元组是因为像素级评估需要掩码。defect_type 用于后续按缺陷类型统计召回率。注意 MVTec 的掩码文件名规则是原图名加 _mask如果你的数据集命名不同改 replace 那一行即可。Normalize 用的 ImageNet 均值方差因为骨干是在 ImageNet 上预训练的这一步不能省否则特征分布偏移会导致异常分数整体漂移。3.2 特征提取与记忆库构建训练阶段的核心是构建正常样本的特征记忆库。Notebook 的做法是遍历训练集的每张正常图提取多尺度特征做自适应平均池化降维然后存成一个大的特征矩阵。推理时算查询特征与记忆库的最近邻距离。# 构建正常样本特征记忆库 import torch import torch.nn.functional as F def build_memory_bank(model, train_loader, device): model.eval() features [] with torch.no_grad(): for imgs, _, _ in train_loader: imgs imgs.to(device) # 提取多尺度特征并拼接 feat model.extract_features(imgs) # shape: (B, C, H, W) # 自适应池化到固定空间尺寸减少记忆库大小 feat F.adaptive_avg_pool2d(feat, (16, 16)) feat feat.permute(0, 2, 3, 1).reshape(-1, feat.size(1)) features.append(feat.cpu()) memory_bank torch.cat(features, dim0) # L2 归一化后续用余弦距离 memory_bank F.normalize(memory_bank, p2, dim1) return memory_bank参数说明adaptive_avg_pool2d 的 (16, 16) 控制记忆库的空间粒度。设成 16 意味着每张图贡献 256 个特征向量100 张正常图就是 25600 个向量内存占用约 150MB512 维 float32。如果设成 32内存翻四倍但小缺陷的定位精度会提升。我一般先用 16 跑通再根据验证集的热力图效果决定要不要加细。3.3 推理与异常热力图生成推理阶段对每张测试图提取特征与记忆库算余弦距离取最小距离作为异常分数再 reshape 回空间维度得到热力图。# 推理生成像素级异常热力图 def predict_anomaly_map(model, memory_bank, img, device): model.eval() with torch.no_grad(): img img.unsqueeze(0).to(device) feat model.extract_features(img) # (1, C, H, W) feat F.adaptive_avg_pool2d(feat, (16, 16)) B, C, H, W feat.shape feat feat.permute(0, 2, 3, 1).reshape(-1, C) feat F.normalize(feat, p2, dim1) # 余弦距离1 - 余弦相似度 dist 1 - torch.mm(feat, memory_bank.t()) # (256, N) min_dist, _ dist.min(dim1) # 每个位置取最近邻距离 anomaly_map min_dist.reshape(H, W).cpu() # 上采样回原图尺寸 anomaly_map F.interpolate( anomaly_map.unsqueeze(0).unsqueeze(0), size(256, 256), modebilinear, align_cornersFalse ).squeeze() return anomaly_map逻辑说明mm 做矩阵乘法算余弦相似度1 减去相似度得到距离。min(dim1) 取每个查询位置与记忆库中所有正常特征的最近距离距离越大越异常。上采样用双线性插值比最近邻更平滑热力图不会出现块状伪影。注意这里没有做高斯平滑Notebook 在可视化单元格里用 scipy.ndimage.gaussian_filter 做后处理sigma 一般设 4。4. 避坑与排查工业异常检测里最容易翻车的五个点4.1 现象正常样本的异常分数比缺陷还高原因预处理不一致。训练时用了 Normalize推理时忘了加或者 resize 的插值方式不同训练用 bilinear推理用 nearest。特征分布偏移导致正常样本被推到记忆库之外。解决把预处理封装成一个 transform 对象训练和推理共用同一个实例。在 Notebook 里加一个断言检查输入张量的均值和方差是否在预期范围内。4.2 现象热力图全图高亮定位完全失效原因记忆库太小或特征多样性不足。如果训练集只有 20 张正常图记忆库覆盖不了正常样本的自然变化推理时任何输入都离记忆库很远。解决训练集至少 100 张起步或者做数据增强扩充正常样本。Notebook 里可以打开 random crop 和 color jitter但注意增强后的图也要当正常样本存入记忆库。4.3 现象小缺陷漏检严重大缺陷过杀原因特征层选择单一。只用 layer3 的话14×14 的特征图对几个像素的划痕不敏感。解决启用多尺度拼接layer2 和 layer3 都用上。如果还不行把 INPUT_SIZE 从 256 提到 512但显存要够。另一个技巧是在热力图后处理时对小于 5 个像素的连通域做抑制减少噪声引起的过杀。4.4 现象换一个产品类别所有参数都要重调原因不同类别的纹理复杂度差异大。金属表面的反光和纺织品的纹理对特征分布的方差影响完全不同。解决Notebook 里每个类别单独建记忆库、单独搜阈值。不要指望一套参数通吃。如果类别多写个循环自动跑每个类别的阈值搜索把结果存成 CSV。4.5 现象GPU 显存溢出batch size 降到 1 还是 OOM原因特征图在内存里没及时释放或者记忆库的 mm 操作产生了大中间矩阵。解决在推理循环里加 torch.cuda.empty_cache()把记忆库转成 float16 存储。mm 操作分块做每次只算 64 个查询位置。Notebook 里有个 memory_efficient_inference 开关打开后显存占用降一半速度慢 20% 左右。5. 从 Notebook 到可复现实验固定随机种子与结果导出5.1 随机种子的完整固定方案Notebook 跑出来的结果每次不一样这是最让人头疼的问题。除了 torch.manual_seed还要固定 numpy、random、cudnn 的种子。更关键的是 DataLoader 的 worker_init_fn多进程加载时每个 worker 的种子要单独设。# 完整随机种子固定 import torch, numpy as np, random, os def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) os.environ[PYTHONHASHSEED] str(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False def worker_init_fn(worker_id): np.random.seed(42 worker_id) random.seed(42 worker_id) # DataLoader 里传入 worker_init_fn train_loader DataLoader(dataset, batch_size8, shuffleTrue, num_workers4, worker_init_fnworker_init_fn)逻辑说明cudnn.deterministic 设为 True 会禁用一些非确定性算法速度可能慢 10% 到 15%但结果可复现。benchmark 设为 False 是因为 benchmark 会自动选最快算法不同运行可能选到不同算法。worker_init_fn 里给每个 worker 分配不同种子避免多个 worker 产出相同的随机增强结果。5.2 结果导出与批量评估Notebook 最后几个单元格做批量评估输出每个类别的图像级 AUROC、像素级 AUROC、PRO 曲线。结果存成 CSV 和 JSON 两份方便后续画图或写论文。# 批量评估并导出结果 import pandas as pd from sklearn.metrics import roc_auc_score results [] for category in [bottle, cable, metal_nut]: img_scores, img_labels [], [] pixel_scores, pixel_labels [], [] for imgs, masks, _ in test_loader: for i in range(imgs.size(0)): amap predict_anomaly_map(model, memory_bank, imgs[i], device) img_scores.append(amap.max().item()) img_labels.append(1 if masks[i].sum() 0 else 0) pixel_scores.extend(amap.flatten().tolist()) pixel_labels.extend(masks[i].flatten().tolist()) results.append({ category: category, img_auroc: roc_auc_score(img_labels, img_scores), pixel_auroc: roc_auc_score(pixel_labels, pixel_scores) }) df pd.DataFrame(results) df.to_csv(eval_results.csv, indexFalse)参数说明img_auroc 衡量图像级分类能力pixel_auroc 衡量像素级定位能力。工业场景更看重 pixel_auroc因为漏检一个小缺陷可能比整图误判代价更高。PRO 曲线需要额外实现Notebook 里有个 pro_curve 函数计算每个连通域的重叠率比 pixel_auroc 更贴近实际产线指标。5.3 一个容易被忽略的细节掩码的膨胀与腐蚀MVTec 的掩码是人工标注的边缘往往比实际缺陷小一圈。直接拿来做像素级评估会低估模型性能。我一般会把掩码做一次膨胀膨胀核大小 3×3再算 pixel_auroc。这个操作在 Notebook 的 eval 单元格里有个开关 MASK_DILATE默认关闭建议打开对比一下差异。从那以后我每次跑像素级评估都强制走一遍掩码膨胀不然论文里的数字自己都不信。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑