基于CNN+PyTorch实现视觉检测分类 原创
本文给出了一个使用CNN+PyTorch实现汽车电子行业视觉检测分类详尽的实战案例解析。
在本文中,我们开发了一个卷积神经网络(CNN),用于汽车电子行业的视觉检测分类任务。在此过程中,我们深入研究了卷积层的概念和相关数学知识,并研究了CNN实际看到的内容以及图像的哪些部分导致它们做出决策。
第1部分:概念背景
1.任务:将工业部件分类为合格件或废品
在自动装配线的一个工位中,带有两个突出金属销的线圈必须精确地定位在外壳中。金属销插入小插座中。在某些情况下,销略微弯曲就会导致无法通过机器连接。视觉检查的任务是识别这些线圈,以便可以自动将它们分类出来。
图1:线圈、外壳和插座
为了进行检查,每个线圈都被单独拾起并放在屏幕前。在这个位置,相机拍摄灰度图像。然后,由CNN检查并分类为合格品或废品。
图2:视觉检查的基本设置和生成的图像
现在,我们要定义一个卷积神经网络,它能够处理图像并从预先分类的标签中学习。
2.什么是卷积神经网络(CNN )?
卷积神经网络是卷积滤波器和全连接神经网络(NN)的组合。CNN通常用于图像处理,例如人脸识别或视觉检查任务,就像我们的情况一样。卷积滤波器是矩阵运算,它在图像上滑动并重新计算图像的每个像素。我们将在本文后面研究卷积滤波器。过滤器的权重不是预设的(例如Photoshop中的锐化函数),而是在训练期间从数据中学习而来。
3.卷积神经网络的架构
首先,让我们来看看CNN架构的一个例子。为方便起见,我们选择了稍后将要实现的模型。
图3:我们的视觉检测CNN架构
我们希望将高度为400像素、宽度为700像素的检测图像输入CNN。由于图像是灰度的,因此相应的PyTorch张量的大小为1x400x700。如果我们使用彩色图像,我们将有3个输入通道:一个用于红色,一个用于绿色,一个用于蓝色(RGB)。在这种情况下,张量将是3x400x700。
第一个卷积滤波器有6个大小为5x5的内核,它们在图像上滑动并生成6个独立的新图像,称为特征图,尺寸略有缩小(6x396x696)。图3中未明确显示ReLU激活。它不会改变张量的维度,但会将所有负值设置为零。ReLU之后是内核大小为2x2的MaxPooling层,它将每幅图像的宽度和高度减半。
所有三层(卷积、ReLU和MaxPooling)都是第二次实施的。这最终为我们带来了16个特征图,图像高度为97像素,宽度为172像素。接下来,所有矩阵值都被展平并输入到全连接神经网络的大小相同的第一层中。它的第二层已经减少到120个神经元。第三层和输出层只有2个神经元:一个代表标签“OK”,另一个代表标签“not OK”或“scrap”。
如果你还不清楚维度的变化,请耐心等待。我们将在下文中详细研究不同类型的层(卷积、ReLU和MaxPooling)的工作原理及其对张量维度的影响。
4.卷积滤波器层
卷积滤波器的任务是查找图像中的典型结构/模式。常用的内核大小为3x3或5x5。内核的9个或25个权重不是预先指定的,而是在训练过程中学习的(这里我们假设只有一个输入通道;否则,权重的数量将乘以输入通道)。内核在水平和垂直方向上以定义的步幅在图像的矩阵表示上滑动(每个输入通道都有自己的内核)。内核和矩阵的对应值相乘并相加。每个滑动位置的求和结果形成新图像,我们将其称为特征图。我们可以在卷积层中指定多个内核。在这种情况下,我们会收到多个特征图作为结果。内核在矩阵上从左到右、从上到下滑动。因此,图4显示了内核在其第五个滑动位置(不计算后面的“...”部分)。我们看到红、绿、蓝(RGB)三个输入通道。每个通道只有一个内核。在实际应用中,我们通常为每个输入通道定义多个内核。
图4:具有3个输入通道和每个通道1个内核的卷积层
内核1为红色输入通道工作。在所示位置,我们计算特征图中的相应新值为(-0.7)*0+(-0.9)*(-0.2)+(-0.6)*0.5+(-0.6)*0.6+0.6*(-0.3)+0.7*(-1)+0*0.7+(-0.1)*(-0.1)+(-0.2)*(-0.1)=(-1.33)。绿色通道(内核2)的相应计算结果为-0.14,蓝色通道(内核3)的相应计算结果为0.69。为了得到特征图中特定滑动位置的最终值,我们将所有三个通道值相加并添加一个偏差(偏差和所有核权重都是在CNN训练期间定义的):(-1.33)+(-0.14)+0.69+0.2=-0.58。该值放置在特征图中以黄色突出显示的位置。
最后,如果我们将输入矩阵的大小与特征图的大小进行比较,我们会发现通过核操作,我们在高度上损失了两行,在宽度上损失了两列。
5.ReLU激活层
卷积后,特征图通过激活层。激活是赋予网络非线性能力所必需的。两种最常用的激活方法是Sigmoid和ReLU(整流线性单元)。ReLU激活将所有负值设置为零,同时保持正值不变。
图5:特征图的ReLU激活
在图5中,我们看到特征图的值逐个元素地通过了ReLU激活。
ReLU激活对特征图的尺寸没有影响。
6.MaxPooling层
池化层的主要任务是减小特征图的大小,同时保留分类的重要信息。通常,我们可以通过计算内核中某个区域的平均值或返回最大值来进行池化。MaxPooling在大多数应用中更有用,因为它可以减少数据中的噪音。池化的典型内核大小为2x2或3x3。
图6:内核为2x2的最大池化和平均池化
在图6中,我们看到了内核大小为2x2的MaxPooling和AvgPooling的示例。特征图被划分为内核大小的区域,在这些区域中,我们取最大值(→MaxPooling)或平均值(→AvgPooling)。
通过2x2核大小的池化,我们将特征图的高度和宽度减半。
7.卷积神经网络中的张量维度
现在,我们已经研究了卷积滤波器、ReLU激活和池化,我们可以修改图3和张量的维度。我们从400x700大小的图像开始。由于它是灰度的,因此只有1个通道,相应的张量大小为1x400x700。我们将6个大小为5x5、步幅为1x1的卷积滤波器应用于图像。每个滤波器都返回自己的特征图,因此我们收到6个。由于与图4相比内核较大(5x5而不是3x3),这次我们在卷积中丢失了4列和4行。这意味着,返回的张量大小为6x396x696。
下一步,我们将具有2x2内核的MaxPooling应用于特征图(每个图都有自己的池化内核)。正如我们所了解的,这会将图的尺寸减少2倍。因此,张量现在的大小为6x198x348。
现在,我们应用16个大小为5x5的卷积滤波器。它们每个的内核深度为6,这意味着每个滤波器为输入张量的6个通道提供单独的层。每个内核层都会在6个输入通道中的一个上滑动,如图4所示,并且6个返回特征图加起来为1。到目前为止,我们只考虑了一个卷积滤波器,但我们有16个。这就是为什么我们收到16个新的特征图,每个特征图比输入小4列和4行。张量大小现在是16x194x3。
第2部分:定义和编码CNN
从概念上讲,我们已经拥有了所需的一切。现在,让我们进入前面1.1节中所描述的工业应用场景。
1.加载所需的库
我们将使用几个PyTorch库来加载数据、采样和模型本身。此外,我们加载matplotlib.pyplot进行可视化,并加载PIL进行图像转换。
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset
from torch.utils.data.sampler import WeightedRandomSampler
from torch.utils.data import random_split
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
import os
import warnings
warnings.filterwarnings("ignore")
2.配置你的设备并指定超参数
在设备中,我们存储“cuda”或“cpu”,具体取决于你的计算机是否有可用的GPU。minibatch_size定义在模型训练期间,一次矩阵运算将处理多少张图像。learning_rate指定反向传播期间参数调整的幅度,epochs定义我们在训练阶段处理整组训练数据的频率。
# 设备配置
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Using {device} device")
# 指定超参数
minibatch_size = 10
learning_rate = 0.01
epochs = 60
3.自定义加载器函数
为了加载图像,我们定义了一个custom_loader。它以二进制模式打开图像,裁剪图像内部的700x400像素,将其加载到内存中,并返回加载的图像。作为图像的路径,我们定义相对路径data/Coil_Vision/01_train_val_test。请确保数据存储在你的工作目录中。你可以从我的Dropbox下载文件CNN_data.zip。
#定义加载器函数
def custom_loader(path):
with open(path, 'rb') as f:
img = Image.open(f)
img = img.crop((50, 60, 750, 460)) #Size: 700x400 px
img.load()
return img
# 图像路径(本地路径以加速加载)
path = "data/Coil_Vision/01_train_val_test"
4.定义数据集
我们将数据集定义为由图像数据和标签组成的元组,0表示废品,1表示合格品。方法datasets.ImageFolder()从文件夹结构中读取标签。我们使用转换函数首先将图像数据加载到PyTorch张量(值介于0和1之间),然后使用近似平均值0.5和标准差0.5对数据进行归一化。转换后,图像数据大致呈标准正态分布(平均值=0,标准差=1)。我们将数据集随机分成50%的训练数据、30%的验证数据和20%的测试数据。
#用于加载的转换函数
transform = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((0.5), (0.5))])
# 在文件夹结构中创建数据集
dataset = datasets.ImageFolder(path, transform=transform, loader=custom_loader)
train_set, val_set, test_set = random_split(dataset, [round(0.5*len(dataset)),
round(0.3*len(dataset)),
round(0.2*len(dataset))])
5.平衡数据集
我们的数据是不平衡的。我们的好样本比废弃样本多得多。为了减少训练期间对多数种类的偏见,我们使用WeightedRandomSampler在采样期间为少数种类提供更高的概率。在lbls中,我们存储训练数据集的标签。使用np.bincount(),我们计算0标签(bc[0])和1标签(bc[1])的数量。接下来,我们计算两个种类(p_nOK和p_OK)的概率权重,并根据lst_train列表中数据集中的顺序排列它们。最后,我们从WeightedRandomSampler实例化train_sampler。
# 定义一个采样器来平衡这些类
# training dataset
lbls = [dataset[idx][1] for idx in train_set.indices]
bc = np.bincount(lbls)
p_nOK = bc.sum()/bc[0]
p_OK = bc.sum()/bc[1]
lst_train = [p_nOK if lbl==0 else p_OK for lbl in lbls]
train_sampler = WeightedRandomSampler(weights=lst_train, num_samples=len(lbls))
6.定义数据加载器
最后,我们为训练、验证和测试数据定义三个数据加载器。数据加载器向神经网络提供一批数据集,每批数据集由图像数据和标签组成。
对于train_loader和val_loader,我们将批处理大小设置为10,并对数据进行随机打乱。test_loader使用随机打乱数据和批处理大小1进行操作。
# 用批尺寸定义加载器
train_loader = DataLoader(dataset=train_set, batch_size=minibatch_size, sampler=train_sampler)
val_loader = DataLoader(dataset=val_set, batch_size=minibatch_size, shuffle=True)
test_loader = DataLoader(dataset=test_set, shuffle=True)
7.检查数据:绘制5个OK和5个nOK部分
为了检查图像数据,我们绘制了5个好样本(“OK”)和5个废品样本(“nOK”)。为此,我们定义了一个2行5列的matplotlib图形,并共享x轴和y轴。在代码片段的核心中,我们嵌套了两个for循环。外循环从train_loader接收数据批次。每个批次包含十张图像和相应的标签。内循环枚举批次的标签。在其主体中,我们检查标签是否等于0—然后我们在第二行的“nOK”下绘制图像—或者如果标签等于1—然后我们在第一行的“OK”下绘制图像。一旦count_OK和count_nOK都大于或等于5,我们就中断循环,设置标题并显示图形。
# 图形和轴对象
fig, axs = plt.subplots(nrows=2, ncols=5, figsize=(20,7), sharey=True, sharex=True)
count_OK = 0
count_nOK = 0
# 循环遍历加载器批次
for (batch_data, batch_lbls) in train_loader:
#循环遍历batch_lbls
for i, lbl in enumerate(batch_lbls):
# 如果标签为0 (nOK),在第1行绘制图形
if (lbl.item() == 0) and (count_nOK < 5):
axs[1, count_nOK].imshow(batch_data[i][0], cmap='gray')
axs[1, count_nOK].set_title(f"nOK Part#: {str(count_nOK)}", fontsize=14)
count_nOK += 1
#如果标签为1 (OK),在第0行绘制图形
elif (lbl.item() == 1) and (count_OK < 5):
axs[0, count_OK].imshow(batch_data[i][0], cmap='gray')
axs[0, count_OK].set_title(f"OK Part#: {str(count_OK)}", fontsize=14)
count_OK += 1
#如果两个计数器都是>=5停止循环
if (count_OK >=5) and (count_nOK >=5):
break
# 配置绘图画布
fig.suptitle("Sample plot of OK and nonOK Parts", fontsize=24)
plt.setp(axs, xticks=[], yticks=[])
plt.show()
图7:OK(上行)和非OK部分(下行)的示例
在图7中,我们看到大多数nOK样本明显弯曲,但有些样本肉眼无法真正区分(例如右下样本)。
8.定义CNN模型
该模型对应于图3中所示的架构。我们将灰度图像(仅一个通道)输入到第一个卷积层,并定义6个大小为5(等于5x5)的内核。卷积后跟ReLU激活和MaxPooling,内核大小为2(2x2),步长为2(2x2)。所有三个操作都以图3中所示的尺寸重复。在__init__()方法的最后一个块中,16个特征图被展平并输入到具有等效输入大小和120个输出节点的线性层中。它被ReLU激活,并在第二个线性层中减少到只有2个输出节点。
在forward()方法中,我们只需调用模型层并输入x张量。
class CNN(nn.Module):
def __init__(self):
super().__init__()
# Define model layers
self.model_layers = nn.Sequential(
nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Flatten(),
nn.Linear(16*97*172, 120),
nn.ReLU(),
nn.Linear(120, 2)
)
def forward(self, x):
out = self.model_layers(x)
return out
9.实例化模型并定义损失函数和优化器
我们从CNN类实例化模型并将其推送到CPU或GPU上。由于我们有一个分类任务,我们选择使用CrossEntropyLoss函数。为了管理训练过程,我们调用随机梯度下降(SGD)优化器。
# 在cpu或gpu上定义模型
model = CNN().to(device)
#损失函数和优化器
loss = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)
10.检查模型的大小
为了了解模型的参数大小,我们迭代model.parameters(),首先将所有模型参数(num_param)相加,其次将反向传播期间要调整的参数(num_param_trainable)相加。最后,我们打印结果。
# Count number of parameters / thereof trainable
num_param = sum([p.numel() for p in model.parameters()])
num_param_trainable = sum([p.numel() for p in model.parameters() if p.requires_grad == True])
print(f"Our model has {num_param:,} parameters. Thereof trainable are {num_param_trainable:,}!")
打印结果告诉我们,该模型有超过3200万个参数,其中所有参数都是可训练的。
11.定义一个用于验证和测试的函数
在开始模型训练之前,让我们准备一个函数来支持验证和测试。函数val_test()需要一个数据加载器和CNN模型作为参数。它使用torch.no_grad()关闭梯度计算并迭代数据加载器。有了一批图像和标签,它将图像输入模型,并使用output.argmax(1)对返回的logits确定模型的预测分类。此方法返回最大值的索引;在我们的例子中,这代表分类索引。
我们计算并总结正确的预测,并保存图像数据、预测的分类和错误预测的标签。最后,我们计算准确率并将其与错误分类的图像一起返回作为函数的输出。
def val_test(dataloader, model):
# 获取数据集大小
dataset_size = len(dataloader.dataset)
# 关闭梯度计算以进行验证
with torch.no_grad():
# 循环数据集
correct = 0
wrong_preds = []
for (images, labels) in dataloader:
images, labels = images.to(device), labels.to(device)
#从模型中获取原始值
output = model(images)
# 推导预测
y_pred = output.argmax(1)
# 对所有批次进行正确的分类计数
correct += (y_pred == labels).type(torch.float32).sum().item()
# Save wrong predictions (image, pred_lbl, true_lbl)
for i, _ in enumerate(labels):
if y_pred[i] != labels[i]:
wrong_preds.append((images[i], y_pred[i], labels[i]))
# Calculate accuracy
acc = correct / dataset_size
return acc, wrong_preds
12.模型训练
模型训练由两个嵌套的for循环组成。外循环迭代定义的epoch数,内循环枚举train_loader。枚举返回一批图像数据和相应的标签。图像数据(images)被传递给模型,我们在输出中接收模型的响应logit。outputs和真实标签被传递给损失函数。基于损失l,我们执行反向传播并使用optimizer.step更新参数。outputs是维度为batchsizex输出节点的张量,在我们的例子中是10x2。我们通过行上最大值的索引(0或1)接收模型的预测。
最后,我们计算正确预测的数量(n_correct)、真正的OK部分(n_true_OK)和样本数量(n_samples)。在每个第二个训练周期,我们计算训练准确率、真正的OK份额,并调用验证函数(val_test())。在训练过程中,所有三个值都会被打印出来以供参考。在最后一行代码中,我们将模型及其所有参数保存在“model.pth”中。
acc_train = {}
acc_val = {}
# 对世代进行迭代处理
for epoch in range(epochs):
n_correct=0; n_samples=0; n_true_OK=0
for idx, (images, labels) in enumerate(train_loader):
model.train()
# 如果可用,请将数据推送到gpu
images, labels = images.to(device), labels.to(device)
#向前传播
outputs = model(images)
l = loss(outputs, labels)
# 向后传播和优化
optimizer.zero_grad()
l.backward()
optimizer.step()
# 获取预测标签(.max返回(value,index))
_, y_pred = torch.max(outputs.data, 1)
# 计算正确的分类
n_correct += (y_pred == labels).sum().item()
n_true_OK += (labels == 1).sum().item()
n_samples += labels.size(0)
# 在世代结束时:计算准确性和打印信息
if (epoch+1) % 2 == 0:
model.eval()
# 计算准确性
acc_train[epoch+1] = n_correct / n_samples
true_OK = n_true_OK / n_samples
acc_val[epoch+1] = val_test(val_loader, model)[0]
#打印信息
print (f"Epoch [{epoch+1}/{epochs}], Loss: {l.item():.4f}")
print(f" Training accuracy: {acc_train[epoch+1]*100:.2f}%")
print(f" True OK: {true_OK*100:.3f}%")
print(f" Validation accuracy: {acc_val[epoch+1]*100:.2f}%")
# 保存模型和状态词典
torch.save(model, "model.pth")
在我的笔记本电脑的GPU上训练需要几分钟。强烈建议从本地驱动器加载图像;否则,训练时间可能会增加几个数量级!
训练的打印输出表明损失已显著减少,验证准确率(模型未用于更新其参数的数据的准确率)已达到98.4%。
如果我们绘制训练和验证准确率在各个时期的图表,则可以更好地了解训练进度。我们可以轻松做到这一点,因为我们每个第二个训练周期都保存了值。
我们用plt.subplots()创建matplotlib图和轴,并在准确率字典的键上绘制值。
# 实例化图形和轴对象
fig, ax = plt.subplots(figsize=(10,6))
plt.plot(list(acc_train.keys()), list(acc_train.values()), label="training accuracy")
plt.plot(list(acc_val.keys()), list(acc_val.values()), label="validation accuracy")
plt.title("Accuracies", fontsize=24)
plt.ylabel("%", fontsize=14)
plt.xlabel("Epochs", fontsize=14)
plt.setp(ax.get_xticklabels(), fontsize=14)
plt.legend(loc='best', fontsize=14)
plt.show()
图8:模型训练期间的训练和验证准确率
13.加载训练好的模型
如果你想将模型用于生产而不仅仅是用于研究目的,强烈建议你保存并加载模型及其所有参数。保存已经是训练代码的一部分。从驱动器加载模型同样简单。
#从文件中读取模型
model = torch.load("model.pth")
model.eval()
14.使用测试数据再次检查模型准确性
请记住,我们保留了另外20%的数据用于测试。这些数据对于模型来说是全新的,之前从未加载过。我们可以使用这些全新的数据再次检查来验证准确性。由于验证数据已加载但从未用于更新模型参数,因此我们期望其准确性与测试值相似。为了进行测试,我们在test_loader上调用val_test()函数。
print(f"test accuracy: {val_test(test_loader,model)[0]*100:0.1f}%")
在具体示例中,我们的测试准确率达到了99.2%,但这在很大程度上取决于机会(记住:图像在训练、验证和测试数据中的随机分布)。
15.可视化错误分类的图像
错误分类的图像的可视化非常简单。首先,我们调用val_test()函数,它返回一个元组。其中,包含索引位置0处的准确率值(tup[0])和索引位置1处的另一个元组(tup[1]),其中包含图像数据(tup[1][0])、预测标签(tup[1][1])和错误分类图像的真实标签(tup[1][2])。如果tup[1]不为空,我们将枚举它并使用适当的标题绘制错误分类的图像。
%matplotlib inline
# Call test function
tup = val_test(test_loader, model)
#检查是否发生了错误的预测
if len(tup[1])>=1:
# 遍历错误预测的图像
for i, t in enumerate(tup[1]):
plt.figure(figsize=(7,5))
img, y_pred, y_true = t
img = img.to("cpu").reshape(400, 700)
plt.imshow(img, cmap="gray")
plt.title(f"Image {i+1} - Predicted: {y_pred}, True: {y_true}", fontsize=24)
plt.axis("off")
plt.show()
plt.close()
else:
print("No wrong predictions!")
在我们的示例中,我们只有一个错误分类的图像,它占测试数据集的0.8%(我们有125张测试图像)。该图像被分类为OK,但标签为nOK。坦率地说,我也会将其错误分类。
图9:错误分类的图像
第3部分:在生产中使用训练好的模型
1.加载模型、所需的库和参数
在生产阶段,我们假设CNN模型已经过训练,并且参数已准备好加载。我们的目标是将新图像加载到模型中,并让其对相应的电子元件是否适合组装进行分类(参见第1.1节)。
我们首先加载所需的库,将设备设置为“cuda”或“cpu”,定义CNN类(与第2.8章完全相同),然后使用torch.load()从文件加载模型。我们需要在加载参数之前定义CNN类;否则,参数无法正确分配。
#加载所需的库
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
from PIL import Image
import os
# 设备配置
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 完全按照第2.8节来定义CNN模型
class CNN(nn.Module):
def __init__(self):
super(CNN, self).__init__()
# 定义模型层
self.model_layers = nn.Sequential(
nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Flatten(),
nn.Linear(16*97*172, 120),
nn.ReLU(),
nn.Linear(120, 2),
#nn.LogSoftmax(dim=1)
)
def forward(self, x):
out = self.model_layers(x)
return out
# 加载模型的参数
model = torch.load("model.pth")
model.eval()
通过运行此代码片段,我们将CNN模型加载到计算机内存中并对其进行参数化。
2.将图像加载到数据集中
至于训练阶段,我们需要准备图像以供CNN模型处理。我们从指定的文件夹加载它们,裁剪内部700x400像素,并将图像数据转换为PyTorch张量。
#定义自定义数据集
class Predict_Set(Dataset):
def __init__(self, img_folder, transform):
self.img_folder = img_folder
self.transform = transform
self.img_lst = os.listdir(self.img_folder)
def __len__(self):
return len(self.img_lst)
def __getitem__(self, idx):
img_path = os.path.join(self.img_folder, self.img_lst[idx])
img = Image.open(img_path)
img = img.crop((50, 60, 750, 460)) #Size: 700x400
img.load()
img_tensor = self.transform(img)
return img_tensor, self.img_lst[idx]
我们在名为Predict_Set()的自定义数据集类中执行所有步骤。在__init__()中,我们指定图像文件夹,接受转换函数,并将图像文件夹中的图像加载到列表self.img_lst中。方法__len__()返回图像文件夹中的图像数量。__getitem__()从文件夹路径和图像名称组成图像路径,裁剪图像的内部部分(就像我们对训练数据集所做的那样),并将转换函数应用于图像。最后,它返回图像张量和图像名称。
3.路径、转换函数和数据加载器
数据准备的最后一步是定义一个数据加载器,允许对图像进行迭代以进行分类。在此过程中,我们指定图像文件夹的路径,并将转换函数定义为管道,首先将图像数据加载到PyTorch张量,其次将数据规范化为大约-1到+1的范围。我们将自定义数据集Predict_Set()实例化为变量predict_set,并定义数据加载器predict_loader。由于我们没有指定批处理大小,predict_loader每次返回一张图像。
# 指向图像的路径(最好是本地路径,以加速加载)
path = "data/Coil_Vision/02_predict"
# 用于加载的转换函数
transform = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((0.5), (0.5))])
# 创建数据集作为自定义数据集的实例
predict_set = Predict_Set(path, transform=transform)
# 定义加载程序
predict_loader = DataLoader(dataset=predict_set)
4.分类自定义函数
到目前为止,用于分类的图像数据的准备工作已经完成。但是,我们仍然缺少一个自定义函数,该函数将图像传输到CNN模型,将模型的响应转换为分类,并返回分类结果。这正是我们使用predict()所做的事情。
def predict(dataloader, model):
# 关闭梯度计算
with torch.no_grad():
img_lst = []; y_pred_lst = []; name_lst = []
#循环遍历数据加载程序
for image, name in dataloader:
img_lst.append(image)
image = image.to(device)
# 从模型中获取原始值
output = model(image)
#推导预测
y_pred = output.argmax(1)
y_pred_lst.append(y_pred.item())
name_lst.append(name[0])
return img_lst, y_pred_lst, name_lst
predict()需要数据加载器和CNN模型作为其参数。其核心是迭代数据加载器,将图像数据传输到模型,并使用output.argmax(1)将模型响应解释为分类结果—0表示废品(nOK),1表示合格零件(OK)。图像数据、分类结果和图像名称附加到列表中,列表作为函数的结果返回。
5.预测标签和绘制图像
最后,我们要利用自定义函数和加载器对新图像进行分类。在文件夹“data/Coil_Vision/02_predict”中,我们保留了四张等待检查的电子元件图像。请记住,我们希望CNN模型告诉我们是否可以使用这些组件进行自动组装,或者是否需要对它们进行分类,因为在尝试将它们推入插座时,引脚可能会引起问题。
我们调用自定义函数predict(),它返回图像列表、分类结果列表和图像名称列表。我们枚举列表并以名称和分类作为标题绘制图像。
# 预测图像的标签
imgs, lbls, names = predict(predict_loader, model)
#对分类图像进行迭代
for idx, image in enumerate(imgs):
plt.figure(figsize=(8,6))
plt.imshow(image.squeeze(), cmap="gray")
plt.title(f"\nFile: {names[idx]}, Predicted label: {lbls[idx]}", fontsize=18)
plt.axis("off")
plt.show()
plt.close()
图10:生产阶段的分类结果
我们看到左侧的两幅图像被归类为合格商品(标签1),右侧的两幅图像被归类为废品(标签0)。由于我们的训练数据,该模型非常敏感,即使针脚有轻微弯曲也会导致它们被归类为废品。
第4部分:CNN在“决策”中考虑了什么?
到目前为止,我们已经深入研究了CNN和我们的工业应用场景的细节。这似乎是一个很好的机会,可以更进一步,尝试了解CNN模型在处理图像数据时“看到”了什么。为此,我们首先研究卷积层,然后检查图像的哪些部分对于分类特别重要。
1.研究卷积滤波器的尺寸
为了更好地理解卷积滤波器的工作原理以及它们对图像的作用,让我们更详细地检查工业示例中的层。
要访问这些层,我们枚举model.children(),它是模型结构的生成器。如果该层是卷积层,我们将其附加到列表all_layers中,并将权重的维度保存在conv_weights中。如果我们有ReLU或MaxPooling层,则没有权重。在这种情况下,我们将层和“*”附加到相应的列表中。接下来,我们枚举all_layers,打印层类型和权重的维度。
# 设置为空的列表,以存储图层和权重
all_layers = []; conv_weights = []
# 迭代模型的结构
# (First level nn.Sequential)
for _, layer in enumerate(list(model.children())[0]):
if type(layer) == nn.Conv2d:
all_layers.append(layer)
conv_weights.append(layer.weight)
elif type(layer) in [nn.ReLU, nn.MaxPool2d]:
all_layers.append(layer)
conv_weights.append("*")
# 打印层和权重维度信息
for idx, layer in enumerate(all_layers):
print(f"{idx+1}. Layer: {layer}")
if type(layer) == nn.Conv2d:
print(f" weights: {conv_weights[idx].shape}")
else:
print(f" weights: {conv_weights[idx]}")
print()
图11:层和权重的维度
请将代码片段的输出与图3进行比较。第一个卷积层有一个输入——只有一个通道的原始图像——并返回六个特征图。我们应用六个内核,每个内核的深度为1,大小为5x5。相应地,权重的维度为torch.Size([6,1,5,5])。相比之下,第4层接收六个特征图作为输入并返回16个图作为输出。我们应用16个卷积内核,每个内核的深度为6,大小为5x5。因此,权重的维度为torch.Size([16, 6, 5, 5])。
2.可视化卷积滤波器的权重
现在,我们知道了卷积滤波器的尺寸。接下来,我们想看看它们的权重,这是它们在训练过程中获得的。由于我们有如此多不同的过滤器(第一个卷积层中有6个,第二个卷积层中有16个),因此在两种情况下,我们都选择第一个输入通道(索引0)。
import itertools
#遍历所有层
for idx_out, layer in enumerate(all_layers):
#如果层是一个卷积滤波器
if type(layer) == nn.Conv2d:
# 打印层名称
print(f"\n{idx_out+1}. Layer: {layer} \n")
# 准备绘图并计算出权重
plt.figure(figsize=(25,6))
weights = conv_weights[idx_out][:,0,:,:] # only first input channel
weights = weights.detach().to('cpu')
# 枚举过滤器权重(仅限第一个输入通道)
for idx_in, f in enumerate(weights):
plt.subplot(2,8, idx_in+1)
plt.imshow(f, cmap="gray")
plt.title(f"Filter {idx_in+1}")
# 打印文本
for i, j in itertools.product(range(f.shape[0]), range(f.shape[1])):
if f[i,j] > f.mean():
color = 'black'
else:
color = 'white'
plt.text(j, i, format(f[i, j], '.2f'), horizontalalignment='center', verticalalignment='center', color=color)
plt.axis("off")
plt.show()
plt.close()
我们遍历all_layers。如果该层是卷积层(nn.Conv2d),则打印该层的索引和该层的核心数据。接下来,我们准备一个图并提取第一个输入层的权重矩阵作为示例。我们枚举所有输出层并使用plt.imshow()绘制它们。最后,我们在图像上打印权重值,以便我们直观地可视化卷积滤波器。
图12:6+16个卷积滤波器的可视化(输入层索引0)
图12显示了第1层的六个卷积滤波器内核和第4层的16个内核(用于输入通道0)。右上角的模型示意图用红色轮廓表示滤波器。我们看到大多数值接近0,有些值在正或负0.20–0.25范围内。这些数字代表图4中卷积所使用的值。这为我们提供了接下来要检查的特征图。
3.检查特征图
根据图4,我们通过输入图像的卷积获得第一个特征图。因此,我们从test_loader加载一个随机图像并将其推送到CPU(如果你在GPU上操作CNN的话)。
# 测试加载程序的批大小为1
img = next(iter(test_loader))[0].to(device)
print(f"\nImage has shape: {img.shape}\n")
# 绘制图像
img_copy = img.to('cpu')
plt.imshow(img_copy.reshape(400,700), cmap="gray")
plt.axis("off")
plt.show()
图13:上述代码的输出为随机图像
现在,我们将图像数据img传递到第一个卷积层(all_layers[0]),并将输出保存在results中。接下来,我们遍历all_layers,并将上一层操作的输出提供给下一层。这些操作是卷积、ReLU激活或MaxPoolings。我们将每个操作的输出附加到results中。
# 将图像通过第一层
results = [all_layers[0](img)]
# 将上一层的结果传递给下一层
for idx in range(1, len(all_layers)): # Start at 1, first layer already passed!
results.append(all_layers[idx](results[-1])) # 将最后一个结果传递给该图层
最后,我们绘制原图以及经过第一层(卷积),第二层(ReLU),第三层(MaxPooling),第四层(第二次卷积),第五层(第二次ReLU),第六层(第二次MaxPooling)之后的特征图。
图14:经过卷积、ReLU和MaxPooling层后的原始图像和特征图
我们看到卷积核(比较图12)重新计算了图像的每个像素。这表现为特征图中灰度值的改变。与原始图像相比,一些特征图更加清晰,或者具有更强的黑白对比度,而其他特征图似乎褪色了。
由于负值设置为零,ReLU操作将深灰色变为黑色。
MaxPooling使图像几乎保持不变,同时在两个维度上将图像大小减半。
4.可视化对分类影响最大的图像区域
在完成之前,让我们分析一下图像的哪些区域对于分类为废品(索引0)或合格部件(索引1)特别具有决定性。为此,我们使用梯度加权类激活映射(gradCAM)。该技术计算训练模型相对于预测类别的梯度(梯度显示输入(图像像素)对预测的影响程度)。每个特征图(=卷积层的输出通道)的梯度平均值构成了计算可视化热图时与特征图相乘的权重。
但是,还是让我们一步一步地分析一下。
def gradCAM(x):
# 运行模型并进行预测
logits = model(x)
pred = logits.max(-1)[-1] # 返回最大值(0或1)
# 在最终的conv层上获取激活量
last_conv = model.model_layers[:5]
activations = last_conv(x)
# 计算关于模型预测的梯度
model.zero_grad()
logits[0,pred].backward(retain_graph=True)
# 计算最后一个层每个输出通道的平均梯度
pooled_grads = model.model_layers[3].weight.grad.mean((1,2,3))
#乘以每个输出通道与其对应的平均梯度
for i in range(activations.shape[1]):
activations[:,i,:,:] *= pooled_grads[i]
# 以所有加权输出通道的平均值计算热图
heatmap = torch.mean(activations, dim=1)[0].cpu().detach()
return heatmap
我们定义一个函数gradCAM,它需要输入数据x(图像或特征图),并返回热图。
在第一个块中,我们在CNN模型中输入x并接收logits,这是一个形状为[1,2]的张量,只有两个值。这些值表示类别0和1的预测概率。我们选择较大值的索引作为模型的预测pred。
在第二个块中,我们提取模型的前五层(从第一个卷积到第二个ReLU),并将它们保存到last_conv。我们在选定的层中运行x并将输出存储在激活中。顾名思义,这些是第二个卷积层(ReLU激活后)的激活(等于特征图)。
在第三个块中,我们对预测类别logits[0,pred]的logit值进行反向传播。换句话说,我们计算CNN相对于预测的所有梯度。梯度显示输入数据(原始图像像素)的变化对模型输出(预测)的影响有多大。结果保存在PyTorch计算图中,直到我们使用model.zero_grad()将其删除。
在第四个块中,我们计算输入通道的梯度平均值,以及图像或特征图的高度和宽度。结果,我们收到从第二个卷积层返回的16个特征图的16个平均梯度。我们将它们保存在pooled_grads中。
在第五个块中,我们迭代从第二个卷积层返回的16个特征图,并使用平均梯度pooled_grads对它们进行加权。此操作对那些对预测具有高重要性的特征图(及其像素)产生更大的影响;反之亦然。从现在开始,激活不再包含特征图,而是加权特征图。
最后,在最后一个块中,我们将热图计算为所有激活的平均特征图。这是函数gradCAM返回的内容。
在绘制图像和热图之前,我们需要对两者进行转换以进行叠加。请记住,特征图比原始图片小(参见第1.3和第1.7节),热图也是如此。这就是我们需要函数upsampleHeatmap()的原因。该函数将像素值缩放到0到255的范围,并将它们转换为8位整数格式(cv2库需要)。它将热图的大小调整为400x700像素,并将颜色图应用于图像和热图。最后,我们叠加70%的热图和30%的图像并返回绘图的合成图。
import cv2
def upsampleHeatmap(map, img):
m,M = map.min(), map.max()
i,I = img.min(), img.max()
map = 255 * ((map-m) / (M-m))
img = 255 * ((img-i) / (I-i))
map = np.uint8(map)
img = np.uint8(img)
map = cv2.resize(map, (700,400))
map = cv2.applyColorMap(255-map, cv2.COLORMAP_JET)
map = np.uint8(map)
img = cv2.applyColorMap(255-img, cv2.COLORMAP_JET)
img = np.uint8(img)
map = np.uint8(map*0.7 + img*0.3)
return map
我们希望将原始图像和热图叠加层并排绘制在同一行中。为此,我们迭代数据加载器predict_loader,在图像上运行gradCAM()函数,在热图和图像上运行upsampleHeatmap()函数。最后,我们使用matplotlib.pyplot将原始图像和热图绘制在同一行中。
#在数据加载器上迭代
for idx, (image, name) in enumerate(predict_loader):
# 计算热图
image = image.to(device)
heatmap = gradCAM(image)
image = image.cpu().squeeze(0).permute(1,2,0)
heatmap = upsampleHeatmap(heatmap, image)
# 绘制图像和热图
fig = plt.figure(figsize=(14,5))
fig.suptitle(f"\nFile: {names[idx]}, Predicted label: {lbls[idx]}\n", fontsize=24)
plt.subplot(1, 2, 1)
plt.imshow(image, cmap="gray")
plt.title(f"Image", fontsize=14)
plt.axis("off")
plt.subplot(1, 2, 2)
plt.imshow(heatmap)
plt.title(f"Heatmap", fontsize=14)
plt.tight_layout()
plt.axis("off")
plt.show()
plt.close()
图15:图像和热图(输出的内侧两行)
热图中的蓝色区域对模型的决策影响较小,而黄色和红色区域则非常重要。我们发现,在我们的使用场景中,电子元件(特别是金属针脚)的轮廓对于将零件分类为废品或合格零件起决定性作用。当然,这是非常合理的,因为此场景中主要处理弯曲的针脚。
结论
卷积神经网络(CNN)如今是工业环境中用于视觉检查任务的常用且广泛使用的工具。在我们的应用场景中,我们用相对较少的代码行成功定义了一个模型,该模型以高精度将电子元件分类为合格零件或废品。与传统的视觉检查方法相比,最大的优势是,没有工艺工程师需要在图像中指定视觉标记来进行分类。相反,CNN从标记的示例中学习,并能够将这些知识复制到其他图像中。在我们的特定场景中,626张标记图像足以进行训练和验证。在更复杂的情况下,对训练数据的需求可能会更高。
gradCAM(梯度加权类激活映射)等算法有助于理解图像中哪些区域与模型的决策特别相关。通过这种方式,它们通过建立对模型功能的信任,支持在工业环境中广泛使用CNN。
总之,在本文中,我们探讨了卷积神经网络内部工作原理的许多细节。希望你享受这段旅程并深入了解CNN的工作原理。
译者介绍
朱先忠,51CTO社区编辑,51CTO专家博客、讲师,潍坊一所高校计算机教师,自由编程界老兵一枚。
原文标题:Building a Vision Inspection CNN for an Industrial Application,作者:Ingo Nowitzky