☰
工业质检异常检测实战:从Notebook到部署的完整指南
2026/10/23 3:10:42 网站建设 项目流程

简介:这份硕士项目资料聚焦工业成像中的异常检测,面向具备一定机器学习基础、希望将算法落地到质量控制与设备故障预防场景的学生和工程师。内容围绕数据预处理、特征提取、模型选择与训练、异常评分与阈值设定、实验评估及可解释性等环节展开,并涉及半导体制造、机械部件检测、纺织品缺陷识别等案例,以及实时系统集成与模型持续更新的工程考量。资源包共13个文件,以txt说明、md文档、py脚本和ipynb笔记本为主,另有gitignore等辅助文件,整体约71KB,结构轻量,便于按模块查阅与复现。其中Jupyter Notebook适合交互式组织代码、文本与可视化,Python脚本可支撑特征处理与模型实验。目前已有1782人学习下载,读者可借此理解从数据处理到模型构建再到工业应用的全流程,掌握异常检测方案的实验比较与评估思路。

1. 工业质检场景下的异常检测:一个值得拆开的硕士项目

产线上的工业相机每秒吐出几十张图,划痕、凹坑、脏污这些缺陷往往只占几个像素,正负样本极度不均衡,标注成本还高得离谱。这个叫 anomaly-detection 的硕士项目,就是冲着这个场景去的——用工业成像做异常检测,把「正常样本建模、异常样本识别」这条链路完整跑通。整个项目以 Jupyter Notebook 为主要载体,适合两类人:一类是想入门工业视觉质检、需要一份能跑通的参考实现的新手;另一类是手里有产线数据、想快速搭个 baseline 再二次开发的熟手。它不追求 SOTA,追求的是流程完整、每一步都能看懂、能改。

2. 项目定位与技术选型:为什么是 Notebook 而不是工程框架

2.1 从工业成像的真实约束倒推技术栈

工业异常检测和通用目标检测最大的区别在于数据分布。产线上正常品占绝大多数,缺陷品稀少且形态多变,你很难收集到覆盖所有缺陷类型的训练集。所以这个项目走的是「无监督/半监督」路线:只用正常样本训练,推理时判断输入偏离正常分布的程度。常见做法是自编码器重构、特征嵌入距离、或者基于预训练特征的记忆库方法。

技术选型上,项目用 Python 生态里最成熟的组合:PyTorch 做模型,torchvision 做图像变换和预训练骨干,NumPy/OpenCV 处理图像读写和可视化,scikit-learn 算 AUC、画 ROC。这些依赖都是工业视觉项目的标配,装起来不折腾。

为什么用 Jupyter Notebook 而不是拆成 .py 工程?硕士项目的核心诉求是「可复现、可讲解、可演示」。Notebook 把数据加载、模型定义、训练循环、评估可视化放在同一个文档里,每一步的输出都能直接看到——这对调试异常检测特别重要,因为你需要肉眼确认「模型认为异常的区域」到底对不对。当然代价是工程化程度低,后面二次开发时我会讲怎么把它拆成模块。

2.2 目录结构:一个典型 Notebook 项目的组织方式

项目结构大致是这样,不同实现会有出入,但骨架类似:

anomaly-detection/ ├── data/ │ ├── train/ # 正常样本,按类别分子目录 │ └── test/ # 测试样本,含正常与异常 ├── notebooks/ │ ├── 01_data_explore.ipynb # 数据探索与可视化 │ ├── 02_train_autoencoder.ipynb # 模型训练 │ └── 03_evaluate.ipynb # 评估与阈值分析 ├── src/ │ ├── dataset.py # Dataset 与 DataLoader 封装 │ ├── model.py # 网络结构定义 │ └── utils.py # 图像预处理、指标计算 ├── checkpoints/ # 保存的模型权重 ├── requirements.txt └── README.md

data/train只放正常样本,这是无监督设定的关键;data/test里正常和异常混在一起,用来算 AUC。notebooks按流程编号,方便按顺序跑。src放可复用的函数,Notebook 里 import 进来用——这样既保留了 Notebook 的交互性,又避免把所有代码堆在单元格里。

2.3 环境准备:依赖装完先跑通一个最小验证

先把环境搭起来。建议用 conda 建独立环境,避免和系统 Python 打架:

# 创建环境,Python 版本按项目 README 来,一般 3.8+ conda create -n anomaly python=3.8 -y conda activate anomaly # 安装核心依赖 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install numpy opencv-python scikit-learn matplotlib jupyter

这里torch的安装源按你的硬件选,有 GPU 就装 CUDA 版本。装完先验证一下:

import torch import cv2 import sklearn print("torch:", torch.__version__) print("cuda available:", torch.cuda.is_available()) print("opencv:", cv2.__version__)

如果cuda available是 False 但你确实有 GPU,多半是装成了 CPU 版,重装对应 CUDA 版本的 torch 即可。这一步别跳过,后面训练慢不慢全看这里。

3. 核心模块拆解:数据管道、模型与训练循环

3.1 数据管道:Dataset 怎么写才不拖后腿

工业图像往往尺寸大、通道数不固定(灰度或 RGB),预处理没做好,训练时 GPU 利用率上不去。项目里 Dataset 的典型写法:

import os import cv2 import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms class AnomalyDataset(Dataset): def __init__(self, root_dir, img_size=256, train=True): self.root_dir = root_dir self.img_size = img_size self.train = train self.samples = [] # 遍历类别子目录,收集所有图像路径 for cls in sorted(os.listdir(root_dir)): cls_dir = os.path.join(root_dir, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): if fname.lower().endswith((".png", ".jpg", ".bmp")): self.samples.append((os.path.join(cls_dir, fname), cls)) # 训练时只做基础增强,不做会改变缺陷形态的强增强 self.transform = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((img_size, img_size)), transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]), # 灰度图单通道 ]) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, cls = self.samples[idx] img = cv2.imread(path, cv2.IMREAD_GRAYSCALE) # 工业图多为灰度 if img is None: raise FileNotFoundError(f"读不到图像: {path}") img = self.transform(img) return img, cls

几个关键点。cv2.imread第二个参数用IMREAD_GRAYSCALE强制灰度,工业相机很多是单通道,统一了省事。Normalize的 mean/std 用 0.5 是常见做法,把像素从 [0,1] 映射到 [-1,1],对自编码器收敛友好。增强只做 Resize 和 ToTensor,不做随机翻转、旋转——因为缺陷的方向和位置本身有语义,乱增强会让模型学到错误的「正常」概念,这是工业场景和自然图像场景的重要区别。

DataLoader 那边:

train_loader = DataLoader( AnomalyDataset("data/train", img_size=256, train=True), batch_size=32, shuffle=True, num_workers=4, # 按 CPU 核数调,Windows 下建议设 0 避免多进程问题 pin_memory=True, # 有 GPU 时开启,加速主机到显存的拷贝 )

num_workers在 Linux 下可以设大一点,Windows 下如果报多进程相关的错,直接设 0 用主进程加载,慢但稳。

3.2 模型:自编码器结构怎么定

自编码器的思路是让网络学会「压缩再还原正常图像」,异常图像因为没见过,还原误差就大。结构上编码器逐层降维,解码器逐层升维:

import torch.nn as nn class ConvAutoencoder(nn.Module): def __init__(self, latent_dim=128): super().__init__() # 编码器:256 -> 128 -> 64 -> 32 self.encoder = nn.Sequential( nn.Conv2d(1, 32, 4, stride=2, padding=1), # 256->128 nn.ReLU(inplace=True), nn.Conv2d(32, 64, 4, stride=2, padding=1), # 128->64 nn.ReLU(inplace=True), nn.Conv2d(64, 128, 4, stride=2, padding=1), # 64->32 nn.ReLU(inplace=True), ) # 解码器:32 -> 64 -> 128 -> 256 self.decoder = nn.Sequential( nn.ConvTranspose2d(128, 64, 4, stride=2, padding=1), nn.ReLU(inplace=True), nn.ConvTranspose2d(64, 32, 4, stride=2, padding=1), nn.ReLU(inplace=True), nn.ConvTranspose2d(32, 1, 4, stride=2, padding=1), nn.Sigmoid(), # 输出归一到 [0,1] ) def forward(self, x): z = self.encoder(x) out = self.decoder(z) return out

编码器用stride=2的卷积代替池化,下采样同时保留空间信息。解码器用ConvTranspose2d上采样,最后Sigmoid把输出压到 [0,1]。注意输入经过 Normalize 后是 [-1,1],而输出是 [0,1],训练时要么把输入也映射回 [0,1],要么把输出改成 Tanh——这里保持输出 Sigmoid,训练前对输入做一次(x+1)/2的还原,或者干脆把 Normalize 的 mean/std 改成 0.5 但用(x*0.5+0.5)反归一化。这个细节不注意,loss 会一直下不去,属于典型的玄学翻车点。

3.3 训练循环:损失、优化器与早停

训练部分逻辑不复杂,但几个超参要盯紧:

import torch from torch import optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = ConvAutoencoder().to(device) criterion = nn.MSELoss() # 重构任务用 MSE optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5) best_loss = float("inf") patience, wait = 10, 0 for epoch in range(100): model.train() running = 0.0 for imgs, _ in train_loader: imgs = imgs.to(device) imgs = (imgs + 1) / 2 # 反归一化到 [0,1],和 Sigmoid 输出对齐 recon = model(imgs) loss = criterion(recon, imgs) optimizer.zero_grad() loss.backward() optimizer.step() running += loss.item() * imgs.size(0) epoch_loss = running / len(train_loader.dataset) print(f"epoch {epoch+1}, loss {epoch_loss:.6f}") # 早停:验证损失不再下降就停 if epoch_loss < best_loss: best_loss = epoch_loss torch.save(model.state_dict(), "checkpoints/best.pth") wait = 0 else: wait += 1 if wait >= patience: print("早停触发") break

lr=1e-3配 Adam 是自编码器的稳妥起点,weight_decay=1e-5轻微正则防过拟合。早停的patience=10意思是连续 10 个 epoch 没进步就停,避免在正常样本上过拟合——自编码器过拟合后,连异常图像都能重构得很好,检测就失效了。保存best.pth而不是最后一个 epoch 的权重,也是这个道理。

4. 评估与阈值:异常分数怎么算、阈值怎么定

4.1 异常分数:逐像素误差还是图像级误差

推理阶段,异常分数有两种粒度。图像级:整张图的重构误差取平均,得到一个标量,用来判断「这张图是不是异常」。像素级:保留每个像素的重构误差,生成一张热力图,用来定位「哪里异常」。工业质检通常两个都要——先判断有没有缺陷,再框出缺陷位置。

import numpy as np def anomaly_score(model, img, device): model.eval() with torch.no_grad(): x = img.unsqueeze(0).to(device) x = (x + 1) / 2 recon = model(x) # 逐像素误差图 error_map = ((recon - x) ** 2).squeeze().cpu().numpy() # 图像级分数:误差图的均值 img_score = error_map.mean() return img_score, error_map

error_map就是像素级异常热力图,img_score是图像级分数。实际用的时候,热力图做高斯模糊再二值化,能得到更干净的缺陷区域。

4.2 阈值选择:为什么不能拍脑袋定

阈值定不好,要么漏检要么误报,产线上都是事故。常见做法是在验证集上扫一遍阈值,画 ROC 曲线,选 Youden 指数最大的点:

from sklearn.metrics import roc_curve, auc # scores: 每张测试图的异常分数, labels: 0 正常 / 1 异常 fpr, tpr, thresholds = roc_curve(labels, scores) roc_auc = auc(fpr, tpr) print(f"AUC: {roc_auc:.4f}") # Youden 指数 = tpr - fpr,取最大值对应的阈值 youden = tpr - fpr best_idx = np.argmax(youden) best_threshold = thresholds[best_idx] print(f"最佳阈值: {best_threshold:.6f}, TPR: {tpr[best_idx]:.4f}, FPR: {fpr[best_idx]:.4f}")

AUC 衡量的是「模型把异常排在正常前面的能力」,和阈值无关,是评估模型本身好坏的核心指标。阈值则要结合业务:漏检代价高就把阈值调低(宁可误报),误报代价高就调高。这个权衡没有标准答案,得和产线的人对齐。

4.3 可视化:把热力图叠回原图

评估不能只看数字,得看图。把误差热力图叠到原图上,一眼就能看出模型关注的位置对不对:

import cv2 import matplotlib.pyplot as plt def visualize(img_path, error_map, threshold): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (error_map.shape[1], error_map.shape[0])) # 误差图归一化到 [0,255] heat = (error_map / (error_map.max() + 1e-8) * 255).astype(np.uint8) heat = cv2.applyColorMap(heat, cv2.COLORMAP_JET) overlay = cv2.addWeighted(cv2.cvtColor(img, cv2.COLOR_GRAY2BGR), 0.6, heat, 0.4, 0) # 阈值二值化,框出异常区域 mask = (error_map > threshold).astype(np.uint8) * 255 contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for c in contours: x, y, w, h = cv2.boundingRect(c) cv2.rectangle(overlay, (x, y), (x+w, y+h), (0, 255, 0), 2) plt.imshow(cv2.cvtColor(overlay, cv2.COLOR_BGR2RGB)) plt.axis("off") plt.show()

addWeighted把热力图和原图按 0.4/0.6 的比例融合,findContours找出超过阈值的连通区域并画框。这一步是给产线人员看的,框准不准直接影响他们信不信这个模型。

5. 避坑与常见问题:那些跑起来才会遇到的坑

5.1 现象:loss 降到很低但 AUC 只有 0.5

原因:自编码器过拟合了正常样本,连异常图像也能重构得很好,导致异常分数没有区分度。解决:加正则(weight_decay)、加早停、或者降低模型容量。更根本的办法是换用基于预训练特征的方法,比如用 ImageNet 预训练的骨干提特征,再算特征空间的距离,泛化性通常比从头训的自编码器好。

5.2 现象:训练时 GPU 利用率只有 20%

原因:数据加载成了瓶颈,num_workers太小或者图像解码太慢。解决:把num_workers调到 CPU 核数的一半左右,pin_memory=True,如果图像特别大,考虑预先离线 Resize 成小图存到磁盘,训练时直接读小图。工业图像动辄 2K、4K,在线 Resize 很吃 CPU。

5.3 现象:Windows 下 DataLoader 报 BrokenPipeError 或卡死

原因:Windows 的多进程 spawn 机制和 Linux 的 fork 不一样,num_workers>0时容易出问题。解决:把num_workers设成 0,或者把训练代码放进if __name__ == "__main__":保护块里。Notebook 里跑的话,直接设 0 最省心。

5.4 现象:异常热力图全是噪点,框不出缺陷

原因:逐像素误差对噪声敏感,尤其是图像边缘和纹理区域。解决:对误差图做高斯模糊(cv2.GaussianBlur)再二值化,或者用形态学操作(开运算去小噪点、闭运算连断区)。另外,阈值不要用全局固定值,可以按图像自适应,比如取误差图均值的 k 倍。

5.5 现象:换一批新数据,AUC 掉得厉害

原因:训练集和测试集的成像条件不一致——光照、曝光、相机型号变了,正常样本的分布就变了。解决:这是工业异常检测最头疼的问题。常见做法是每换一批数据就重新采集正常样本微调模型,或者用域自适应方法对齐特征分布。没有银弹,只能靠数据管理流程兜底。

6. 二次开发:从 Notebook 到可部署服务的改造思路

Notebook 跑通只是第一步,真要上产线,得把它拆成能部署的服务。我的改造路径通常是三步。

第一步,把src里的函数抽干净。dataset.py、model.py、utils.py里的代码本来就是为复用写的,直接 import 就行。Notebook 里只留探索性代码,训练和推理逻辑全部下沉到src。这样改模型结构时只动一个文件,不用在 Notebook 里翻单元格。

第二步,写一个推理脚本,把模型加载、预处理、推理、后处理串起来:

# src/inference.py import torch import cv2 import numpy as np from model import ConvAutoencoder class AnomalyDetector: def __init__(self, ckpt_path, img_size=256, threshold=0.01, device="cpu"): self.device = torch.device(device) self.img_size = img_size self.threshold = threshold self.model = ConvAutoencoder().to(self.device) self.model.load_state_dict(torch.load(ckpt_path, map_location=self.device)) self.model.eval() def predict(self, img_path): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (self.img_size, self.img_size)) x = torch.from_numpy(img).float().unsqueeze(0).unsqueeze(0) / 255.0 x = (x - 0.5) / 0.5 # 和训练时的 Normalize 对齐 with torch.no_grad(): recon = self.model(x.to(self.device)) x01 = (x.to(self.device) + 1) / 2 error_map = ((recon - x01) ** 2).squeeze().cpu().numpy() score = float(error_map.mean()) is_defect = score > self.threshold return {"score": score, "is_defect": is_defect, "error_map": error_map}

这个类封装了完整的推理链路,外部只需要传图片路径。threshold作为构造参数,方便按产线调。map_location保证在有 GPU 训练的权重能在 CPU 机器上加载。

第三步,用 FastAPI 或 Flask 包一层 HTTP 接口,产线的工控机通过 HTTP 调用。如果延迟要求高,可以考虑 ONNX 导出 + ONNX Runtime,推理速度能快不少。导出时注意Sigmoid和反归一化逻辑要一起固化进去,别留在 Python 侧。

验证改造是否成功,我的习惯是拿一批标注好的测试图,对比 Notebook 里的 AUC 和部署后的 AUC,误差超过 0.01 就说明预处理或后处理有出入,得逐环节排查。从那以后我每次做模型部署,都强制走一遍「Notebook 结果 vs 服务结果」的对齐验证,这个后悔药吃一次就够了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询