
简介这份硕士项目资料聚焦工业成像中的异常检测面向具备一定机器学习基础、希望将算法落地到质量控制与设备故障预防场景的学生和工程师。内容围绕数据预处理、特征提取、模型选择与训练、异常评分与阈值设定、实验评估及可解释性等环节展开并涉及半导体制造、机械部件检测、纺织品缺陷识别等案例以及实时系统集成与模型持续更新的工程考量。资源包共13个文件约71KB以txt说明、md文档、py脚本和ipynb笔记本为主其中Jupyter Notebook便于在交互环境中组织代码、文本与可视化脚本与说明文件则辅助复现实验流程。目录中可见PADIM、Patch-SVDD、SPADE、EGBAD、RIAD等方法模块以及GDXRay、MVTec等数据集入口方便对照不同异常检测策略。目前已有1780人学习适合作为工业异常检测课题的入门参考与实验起点。1. 工业成像异常检测为什么你的模型在产线上总翻车工业成像里的异常检测和学术数据集上刷 AUC 完全是两码事。很多做硕士项目的同学在 MVTec 这类公开数据集上把图像级 AUROC 跑到 99%信心满满地拿到产线上一跑漏检率直接飙到两位数。问题不在模型本身而在于工业场景的异常定义、成像条件和数据分布跟学术 benchmark 之间隔着一道巨大的鸿沟。这个方向要解决的核心问题是在正常样本充足、异常样本稀少甚至无法穷举的前提下让模型学会“什么是正常的”然后把偏离正常的东西揪出来。它适合做视觉检测、工业质检、医学影像方向的硕士课题也适合想从通用 CV 转向工业落地的工程师。接下来我会把这条路径从原理到代码到踩坑完整拆一遍尽量让你少走弯路。2. 异常检测的三种技术路线重建、嵌入、合成到底选哪个工业异常检测的方法论大致可以分成三条路基于重建的方法、基于特征嵌入的方法、以及基于合成异常的方法。选哪条路取决于你的数据条件、算力预算和产线对推理速度的要求。这一章先把三条路的技术逻辑讲清楚再给出选型判断依据。2.1 基于重建自编码器与生成模型的基本逻辑基于重建的思路很直觉用大量正常样本训练一个模型让它学会把正常图像压缩再还原。推理时如果输入是正常图像重建误差应该很小如果输入包含异常区域模型没见过这种东西重建就会失败误差图就是异常热力图。最经典的基线是 AutoEncoder。编码器把图像压到低维隐空间解码器再还原回来。训练损失就是输入和输出之间的 MSE 或 SSIM。听起来简单但实际用的时候有个致命问题如果自编码器容量太大它连异常区域也能重建得不错导致漏检。这就是所谓的“identity shortcut”问题。import torch import torch.nn as nn class ConvAutoEncoder(nn.Module): def __init__(self, latent_dim256): super().__init__() # 编码器逐步下采样提取空间特征 self.encoder nn.Sequential( nn.Conv2d(3, 32, 4, stride2, padding1), # 256 - 128 nn.BatchNorm2d(32), nn.LeakyReLU(0.2), nn.Conv2d(32, 64, 4, stride2, padding1), # 128 - 64 nn.BatchNorm2d(64), nn.LeakyReLU(0.2), nn.Conv2d(64, 128, 4, stride2, padding1), # 64 - 32 nn.BatchNorm2d(128), nn.LeakyReLU(0.2), ) # 解码器逐步上采样还原 self.decoder nn.Sequential( nn.ConvTranspose2d(128, 64, 4, stride2, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.ConvTranspose2d(64, 32, 4, stride2, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.ConvTranspose2d(32, 3, 4, stride2, padding1), nn.Sigmoid(), # 输出归一化到 [0,1] ) def forward(self, x): z self.encoder(x) return self.decoder(z) # 训练循环核心只喂正常样本最小化重建误差 model ConvAutoEncoder() optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.MSELoss() for batch in normal_dataloader: img batch.to(device) recon model(img) loss criterion(recon, img) optimizer.zero_grad() loss.backward() optimizer.step()这段代码里几个关键参数值得注意。latent_dim控制隐空间维度太小会导致正常样本也重建不好太大则容易过拟合到异常。LeakyReLU(0.2)的负斜率在重建任务里比 ReLU 更稳因为梯度不会在负区完全死掉。输出层用Sigmoid是因为输入图像归一化到了 [0,1]如果输入用的是 [-1,1] 归一化这里应该换成Tanh。推理阶段的异常评分通常用逐像素的 MSE然后做高斯平滑得到异常热力图。图像级评分取热力图的 max 或 top-k 均值。这里有个经验用 SSIM 替代 MSE 做评分对光照变化更鲁棒但计算慢一些。基于重建的方法适合纹理类异常检测比如布匹、金属表面的划痕。但对于结构性异常比如缺失零件、错位装配重建方法往往力不从心因为异常区域在低维隐空间里可能被“平滑”掉了。2.2 基于特征嵌入PatchCore 为什么成为工业基线PatchCore 是这几年工业异常检测里被引用最多的方法之一核心思想是用一个在 ImageNet 上预训练好的 CNN 提取正常样本的特征存成一个 memory bank推理时看测试图像的特征跟 memory bank 里的特征距离有多远。它的优势在于不需要训练直接用预训练特征省去了训练不稳定和过拟合的麻烦。具体流程是把正常训练集的每张图切成 patch每个 patch 过一个预训练 backbone通常是 WideResNet50取中间层的特征做 coreset 采样压缩 memory bank推理时用 kNN 找最近邻距离作为异常分数。import torch import torch.nn.functional as F from torchvision.models import wide_resnet50_2 class PatchCoreFeatureExtractor: def __init__(self, devicecuda): self.device device # 加载预训练 backbone取中间层输出 backbone wide_resnet50_2(pretrainedTrue) self.layer2 backbone.layer2 # 浅层纹理细节 self.layer3 backbone.layer3 # 深层语义信息 self.layer2.eval().to(device) self.layer3.eval().to(device) torch.no_grad() def extract(self, x): # x: [B, 3, H, W] f2 self.layer2(x) # [B, 512, H/8, W/8] f3 self.layer3(x) # [B, 1024, H/16, W/16] # 把 f3 上采样到 f2 的空间尺寸后拼接 f3_up F.interpolate(f3, sizef2.shape[2:], modebilinear) feat torch.cat([f2, f3_up], dim1) # [B, 1536, H/8, W/8] # 局部邻域聚合增强空间一致性 feat F.avg_pool2d(feat, kernel_size3, stride1, padding1) return feat def build_memory_bank(self, dataloader, sample_ratio0.1): all_feats [] for batch in dataloader: img batch.to(self.device) feat self.extract(img) B, C, H, W feat.shape # 展平成 patch 向量集合 feat feat.permute(0, 2, 3, 1).reshape(-1, C) all_feats.append(feat) all_feats torch.cat(all_feats, dim0) # coreset 采样随机保留一部分实际项目可用 k-center greedy n all_feats.shape[0] idx torch.randperm(n)[:int(n * sample_ratio)] return all_feats[idx]sample_ratio是 memory bank 的压缩比例通常取 0.01 到 0.1。太小会丢失正常模式的多样性太大会拖慢推理。实际项目里更推荐用 k-center greedy 做 coreset 采样虽然慢但覆盖更均匀。layer2和layer3的拼接是 PatchCore 的关键设计浅层特征保留纹理细节深层特征提供语义上下文两者互补。推理时对每个测试 patch 特征在 memory bank 里找 k 个最近邻取距离的加权和作为该位置的异常分数。图像级分数取所有 patch 分数的最大值。这里有个工程细节memory bank 如果存了 10 万个向量暴力 kNN 在产线上根本跑不动需要用 FAISS 建索引。PatchCore 在 MVTec 上的图像级 AUROC 通常能到 99% 以上但它的短板是推理速度受 memory bank 大小影响明显。如果产线节拍要求单张图 50ms 以内memory bank 得压到 1 万条以下这时候精度会掉几个点。2.3 基于合成异常DRAEM 与 CutPaste 的取舍合成异常的思路是既然异常样本少那我就人为造一批假异常出来把无监督问题转化成有监督问题。CutPaste 是最简单的做法从正常图像里随机抠一块旋转或变色后贴回去作为异常样本。DRAEM 更进一步用一个独立的生成网络合成纹理异常再用判别网络做分割。import random import torchvision.transforms as T class CutPasteAugment: CutPaste 数据增强从正常图中裁剪 patch 并粘贴到随机位置 def __init__(self, area_ratio(0.02, 0.15), aspect_range(0.3, 3.3)): self.area_ratio area_ratio self.aspect_range aspect_range def __call__(self, img): W, H img.size area W * H * random.uniform(*self.area_ratio) aspect random.uniform(*self.aspect_range) w int((area * aspect) ** 0.5) h int((area / aspect) ** 0.5) # 随机裁剪位置 x1 random.randint(0, W - w) y1 random.randint(0, H - h) patch img.crop((x1, y1, x1 w, y1 h)) # 随机旋转后粘贴到新位置 patch patch.rotate(random.choice([0, 90, 180, 270])) x2 random.randint(0, W - w) y2 random.randint(0, H - h) img.paste(patch, (x2, y2)) return imgCutPaste 的area_ratio控制粘贴块的大小占比太小模型学不到东西太大又不像真实缺陷。aspect_range控制长宽比模拟不同形状的缺陷。这个增强方式实现简单但有个明显问题粘贴边界会有硬边模型可能学会检测“硬边”而不是真正的异常。缓解办法是对粘贴区域做高斯模糊或泊松融合。DRAEM 的效果通常比 CutPaste 好因为它合成的异常更接近真实纹理但训练成本高不少需要同时训练生成器和分割网络。如果算力有限、时间紧CutPaste 加一个强 backbone 是性价比更高的选择。选型建议纹理类异常优先考虑 PatchCore结构性异常可以试 DRAEM算力极度受限时用 CutPaste 加 ResNet 做分类。实际项目里我一般会先用 PatchCore 跑一个 baseline看漏检集中在哪些区域再决定要不要换方法。3. 从零搭一个可复现的异常检测 pipeline这一章把数据准备、训练、评估、可视化的完整流程串起来。假设你手头有一批正常样本和少量标注异常样本目标是搭一个能跑通、能出报告的最小系统。3.1 数据组织与 MVTec 格式对齐工业项目里数据组织往往比模型本身更耗时间。推荐直接对齐 MVTec 的目录结构这样开源代码和预训练权重都能直接用。# 推荐的目录结构 data/ ├── train/ │ └── good/ # 只放正常样本 │ ├── 001.png │ └── 002.png ├── test/ │ ├── good/ # 测试用正常样本 │ ├── scratch/ # 异常类型1 │ ├── dent/ # 异常类型2 │ └── contamination/ # 异常类型3 └── ground_truth/ ├── scratch/ # 对应的像素级标注 mask ├── dent/ └── contamination/训练集只放正常样本这是异常检测的基本设定。测试集按异常类型分文件夹方便后续做分类别的漏检分析。ground_truth里放二值 mask白色是异常区域黑色是背景。如果标注是多边形格式需要先转成 mask转换脚本用 OpenCV 的fillPoly就行。数据量方面每个异常类型至少要有 10 到 20 张测试图否则评估结果波动会很大。正常训练样本通常 200 张起步纹理简单的场景 100 张也能跑但结构复杂的场景建议 500 张以上。3.2 训练循环与早停策略以 PatchCore 为例它其实没有传统意义上的训练循环核心是构建 memory bank。但如果用 DRAEM 或 AutoEncoder就需要写训练循环。def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0.0 for img, _ in dataloader: img img.to(device) recon model(img) loss criterion(recon, img) optimizer.zero_grad() loss.backward() # 梯度裁剪防止重建任务里的梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() return total_loss / len(dataloader) # 早停验证集用正常样本的重建误差误差不再下降就停 best_loss float(inf) patience 10 wait 0 for epoch in range(100): train_loss train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss evaluate_recon(model, val_loader, criterion, device) if val_loss best_loss: best_loss val_loss torch.save(model.state_dict(), best.pth) wait 0 else: wait 1 if wait patience: print(fEarly stop at epoch {epoch}) breakclip_grad_norm_的max_norm1.0是经验值重建任务里梯度爆炸不算常见但一旦出现就会导致 loss 变 NaN。早停的patience10意味着验证损失连续 10 个 epoch 不下降就停这个值可以根据数据集大小调整小数据集用 5 到 10大数据集可以放宽到 20。验证集怎么选从正常训练样本里切 10% 出来不参与训练只用来监控重建误差。注意验证集也必须是正常样本不能用异常样本做早停判断否则就泄露了。3.3 评估指标AUROC 之外的漏检率与过杀率学术论文里几乎只用 AUROC但产线上真正关心的是漏检率Miss Rate和过杀率Overkill Rate。漏检率是异常被判成正常的比例过杀率是正常被判成异常的比例。这两个指标跟阈值直接相关而 AUROC 是阈值无关的。import numpy as np from sklearn.metrics import roc_auc_score, precision_recall_curve def evaluate_detection(scores, labels, threshold): scores: 模型输出的异常分数越高越异常 labels: 0 表示正常1 表示异常 threshold: 判定阈值 preds (scores threshold).astype(int) # 漏检率异常样本中被判为正常的比例 miss_rate np.sum((labels 1) (preds 0)) / np.sum(labels 1) # 过杀率正常样本中被判为异常的比例 overkill_rate np.sum((labels 0) (preds 1)) / np.sum(labels 0) # AUROC 作为参考 auroc roc_auc_score(labels, scores) return { miss_rate: miss_rate, overkill_rate: overkill_rate, auroc: auroc } # 阈值选择在验证集上找漏检率满足要求时过杀率最低的点 precisions, recalls, thresholds precision_recall_curve(val_labels, val_scores) # 实际项目里通常先定漏检率上限再选阈值 target_miss_rate 0.01 valid_idx np.where((1 - recalls) target_miss_rate)[0] best_threshold thresholds[valid_idx[np.argmax(precisions[valid_idx])]]阈值选择是工业落地的关键。学术上常用 F1 最大化的阈值但产线上通常先定一个漏检率上限比如 1%在这个约束下选过杀率最低的阈值。target_miss_rate根据产线质量要求设定汽车零部件可能要求 0.1%一般消费品 1% 到 5% 都能接受。还有一个容易被忽略的指标异常定位的 IoU。图像级判定为异常之后产线操作员还需要知道异常在哪。如果热力图定位不准复检效率会大打折扣。计算像素级 AUROC 或 PROPer-Region Overlap能反映定位质量。4. 工业成像异常检测的避坑与排查清单这一章记录几个我在实际项目里踩过的坑每个都按现象、原因、解决来写。有些坑在学术数据集上根本遇不到但一到产线就暴露。4.1 坑一正常样本里混进了异常模型直接学歪现象训练 loss 正常下降但验证集上漏检率居高不下可视化发现模型把某些缺陷区域也当成正常重建了。原因训练集里混进了少量带缺陷的样本。工业现场采集数据时操作员可能没仔细筛选或者缺陷太细微肉眼没看出来。异常检测的前提是训练集“纯净”一旦混入异常模型就学会了重建这些异常。解决训练前用聚类或孤立森林做一轮粗筛把离群样本挑出来人工复核。具体做法是用预训练特征提取每张图的全局向量跑 K-Means看哪些样本离聚类中心最远。另外训练完成后检查重建误差最高的前 20 张训练图如果里面有明显缺陷说明数据清洗没做到位。4.2 坑二光照变化被当成异常过杀率爆炸现象同一批产品换一个班次或换一盏灯过杀率从 2% 飙到 30%。原因模型学到的是“亮度分布”而不是“结构异常”。基于重建的方法对光照特别敏感因为光照变化直接改变像素值重建误差自然变大。解决训练时加颜色抖动和高斯噪声增强让模型对光照变化鲁棒。推理前做直方图均衡化或自适应归一化把不同批次的图像拉到同一亮度分布。更彻底的做法是改用特征嵌入方法因为预训练特征对光照的敏感度远低于像素级重建。4.3 坑三memory bank 太大推理超时现象PatchCore 在测试集上精度很好但部署到产线后单张图推理要 300ms节拍要求是 80ms。原因memory bank 存了 20 万个特征向量每次推理都要做全量 kNN计算量随 bank 大小线性增长。解决用 FAISS 建 IVF 索引把暴力搜索换成近似最近邻速度能提升 10 倍以上。同时把sample_ratio从 0.1 降到 0.01memory bank 压到 2 万条以内。如果精度掉得太多可以按异常类型分别建 bank推理时只查最相关的那个。import faiss import numpy as np # 假设 memory_bank 是 [N, C] 的 numpy 数组 N, C memory_bank.shape # IVF 索引把空间分成 sqrt(N) 个簇 nlist int(np.sqrt(N)) quantizer faiss.IndexFlatL2(C) index faiss.IndexIVFFlat(quantizer, C, nlist, faiss.METRIC_L2) index.train(memory_bank) index.add(memory_bank) # 推理时只搜索最近的 nprobe 个簇 index.nprobe 8 # 越大越准越小越快 distances, indices index.search(query_feat, k1)nprobe控制搜索多少个簇取 8 到 16 之间通常能在速度和精度之间取得平衡。nlist取sqrt(N)是 FAISS 的推荐值太大索引构建慢太小搜索范围大。4.4 坑四阈值在不同产品间不能复用现象A 产品上调好的阈值直接用到 B 产品上漏检率直接翻倍。原因不同产品的纹理复杂度、缺陷对比度、成像条件都不一样异常分数的分布自然不同。一个固定阈值不可能通用。解决每个产品单独标定阈值。产线换型时用新产品的少量正常样本和已知异常样本重新跑一遍阈值选择流程。如果新产品没有异常样本可以用正常样本的分数分布做无监督阈值设定比如取 99.5 分位数作为阈值。更工程化的做法是把阈值选择做成配置项跟产品型号绑定。4.5 坑五数据增强把异常特征也增强了现象用了 CutPaste 之后模型对真实缺陷的检测反而变差了。原因CutPaste 合成的异常和真实缺陷的形态差异太大模型学会了检测“粘贴边界”这种伪特征而不是真实缺陷的纹理。另外如果增强强度太大正常样本的分布也被改变了。解决降低 CutPaste 的使用概率不要每张图都做增强控制在 30% 到 50%。同时加高斯模糊软化粘贴边界减少伪特征。如果真实缺陷有明确的形态特征优先用 DRAEM 这类基于纹理合成的方案而不是简单的 CutPaste。5. 把异常检测推到产线几个能直接用的进阶技巧前面四章把方法论和 pipeline 讲完了这一章收在几个实战技巧上。这些技巧不一定写在论文里但在产线落地时能帮你省不少时间。第一个技巧是多尺度评分融合。单一尺度的异常分数往往不够稳尤其是缺陷大小差异大的场景。做法是在 backbone 的不同层级分别提取特征、分别算异常分数然后加权融合。浅层分数对小缺陷敏感深层分数对大缺陷敏感。权重可以用验证集上的漏检率来调通常浅层权重 0.4、深层 0.6 是个不错的起点。第二个技巧是利用无标注异常做半监督微调。产线上总能攒到一些被操作员拦下来的异常样本虽然没标注像素级 mask但图像级标签是有的。用这些样本对模型做一轮微调把异常分数拉高能显著降低漏检。注意微调时学习率要小1e-5 量级否则容易把预训练特征带偏。第三个技巧是时序一致性过滤。工业产线通常是连续采集同一个产品可能有多帧图像。如果某一帧被判为异常但相邻帧都正常大概率是噪声导致的误报。用一个简单的滑动窗口做投票能压掉不少过杀。窗口大小取 3 到 5 帧投票阈值取 2/3 或 3/5。第四个技巧是异常热力图的后处理。模型输出的热力图往往有噪声直接拿来做定位效果不好。先做高斯平滑再用连通域分析去掉面积过小的区域最后用形态学闭运算把断裂的异常区域连起来。这几个操作在 OpenCV 里都是现成的加起来不到 20 行代码但定位 IoU 能提升 5 到 10 个点。import cv2 import numpy as np def postprocess_heatmap(heatmap, min_area50): heatmap: [H, W] 浮点异常分数图已归一化到 [0,1] min_area: 最小连通域面积小于此值的区域视为噪声 # 高斯平滑抑制孤立噪点 smoothed cv2.GaussianBlur(heatmap, (5, 5), sigmaX1.5) # 二值化阈值取 0.5 是经验值可根据验证集调整 binary (smoothed 0.5).astype(np.uint8) # 连通域分析去掉小面积区域 num_labels, labels, stats, _ cv2.connectedComponentsWithStats(binary, connectivity8) clean np.zeros_like(binary) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] min_area: clean[labels i] 1 # 形态学闭运算连接断裂区域 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) clean cv2.morphologyEx(clean, cv2.MORPH_CLOSE, kernel) return cleanmin_area根据实际缺陷的最小尺寸来定如果产线要求检测 0.5mm 的划痕按像素当量换算一下就行。sigmaX1.5的高斯核大小取 5这是速度和效果的折中核再大定位会变糊。最后一个习惯每次换产品、换光源、换相机都重新跑一遍验证集看漏检率和过杀率有没有漂移。工业现场的环境变化比实验室里大得多模型不是一次调好就能一直用的。我一般会在产线部署后的第一周每天抽检 50 张图确认指标稳定后再放宽监控频率。希望帮到你。本文还有配套的精品资源点击获取