作者:禅与计算机程序设计艺术
1.简介
语义分割(Semantic Segmentation)任务是对图像中的每个像素进行分类,将不同类别的像素分配给不同的掩模或区域。通过将图像像素划分到各个类别中,实现对物体、空间、动作等特征的提取和理解,从而实现视觉-认知系统的功能拓展。语义分割技术在自动驾驶、智能医疗、机器人领域都有着广泛的应用。当前,通过端到端训练方法获得准确的语义分割模型已经成为一种新型的模式,然而,如何利用现有的开源框架快速搭建训练模型仍然是一个难题。本文将详细介绍语义分割模型的构建、优化技巧、数据集准备、网络结构选型、超参数设置以及代码实现,并对此过程中可能遇到的一些坑点进行阐述。最后,将介绍到目前为止语义分割领域比较热门的一些模型,并讨论如何迁移学习方法来适应不同的场景。读者可以通过本文了解到:
- 如何构建语义分割模型;
- 数据集准备时应该注意哪些细节;
- 如何设计网络结构;
- 不同模型训练技巧之间的差异及其优劣;
- 在训练语义分割模型时可以使用的工具和库;
- 使用迁移学习方法时需要注意哪些细节;
- 当前主流的语义分割模型及其优缺点。
2.基本概念术语说明
2.1 语义分割
语义分割即把图像中的每一个像素根据所属的语义标签进行分类,它用于对图像中的每个像素进行分类,将不同类别的像素分配给不同的掩模或区域。语义分割被广泛应用于各种计算机视觉、自然语言处理等领域。常用的语义分割任务包括目标检测、实例分割、分类、变化检测、人像分割、路牌分割、场景解析、海洋分割等。语义分割主要是基于底层语义信息的图像分析。对图像中的每个像素进行标签分类,称之为“语义分割”。如图1所示,左边的图为无语义分割,右边的图为语义分割后的结果。
2.2 卷积神经网络
卷积神经网络是深度学习的一种重要类型,通过对输入图像进行多次卷积和池化操作,获取图像局部信息,并输出识别结果。卷积神经网络(Convolutional Neural Network, CNN)一般由卷积层、池化层、归一化层、激活函数层、全连接层组成。其中,卷积层负责提取图像的特征,池化层则对卷积层的输出进行整合,激活函数层则用于激活神经元,全连接层则将神经网络的输出变换到下一层进行预测。
2.3 语义分割模型
语义分割模型即CNN+全连接层构成的网络,可以用于对图像进行语义分割。语义分割模型通常由两个部分组成,即特征提取器和分割头。特征提取器包括卷积层、池化层、反卷积层等,分别用来提取图片特征、减少计算量、恢复尺寸。分割头则包含多个卷积层、上采样层、softmax层等,用于对不同种类的对象进行分割。语义分割模型的典型结构如图2所示。
2.4 交叉熵损失函数
交叉熵损失函数,又叫做categorical cross entropy loss function,用于衡量预测值与实际值之间的距离。交叉熵损失函数可由两部分组成,即标签概率分布和真实标签概率分布。标签概率分布指的是模型预测得到的标签的概率分布,而真实标签概率分布指的是真实标签的概率分布。交叉熵损失函数最大的特点就是能够衡量模型预测的精确程度。在训练语义分割模型时,最常用的是Dice系数作为评价指标,它是一个三变量评价指标,包括TPR、TNR和FPR,它代表了模型对于真阳性的分类能力、对伪阳性的分辨能力和错误判定率,Dice系数越高,模型对于真阳性的分类能力和对伪阳性的分辨能力越强,误判率越低。
2.5 池化层
池化层,也叫做下采样层,是卷积神经网络中用来减小图像大小的一种方法。池化层使用一个池化核将邻近的像素块合并为一个输出值,这样就可以降低参数数量,防止过拟合。池化层有两种形式,一种是最大池化,另一种是平均池化。最大池化是将池化核内的最大值作为输出值,平均池化则是将池化核内的平均值作为输出值。池化层的作用是为了降低参数的个数,从而提升模型的效率。
3.核心算法原理和具体操作步骤以及数学公式讲解
语义分割任务需要一个分割头网络,该网络在卷积层提取全局特征后,将其转化为感兴趣区域内的语义标签,以便更好地实现分割任务。本文从模型设计、训练、评估以及测试四个方面介绍语义分割的相关技术。
3.1 模型设计
3.1.1 特征提取器设计
语义分割模型的特征提取器有很多种选择,比如VGGNet、ResNet、DenseNet等,其中,ResNet和DenseNet在多个任务上都有很好的效果。ResNet通过堆叠多个残差单元模块(Residual Units),并对其进行跳跃连接,在保证准确率的同时降低参数数量。DenseNet是由稠密连接的网络块组成,每一层只有前面的几个层连接到后面几个层。它的优点是能够学习到深层次的特征,并通过传递多个不同尺度的特征学习全局上下文信息。因此,DenseNet可以有效解决特征不足的问题,且不需要预先训练,直接通过堆叠就能生成高质量的特征。
特征提取器通常由卷积层、池化层、反卷积层等组合而成,卷积层提取局部特征,池化层对提取出的特征进行整合,反卷积层增强特征的分辨力和多样性。由于卷积核的尺寸大小、深度、通道数量等因素影响最终的性能,因此,特征提取器的设计需要考虑模型的复杂度、数据集大小、分类类别数量、目标尺寸等因素。
3.1.2 分割头设计
语义分割模型的分割头通常由多个卷积层、上采样层、softmax层等组合而成。卷积层提取图像的语义信息,上采样层用来提升分割结果的分辨率,softmax层将卷积层输出的特征映射到语义标签上。
卷积层的参数量较多,如果选用不合适的卷积核大小,就会导致参数量过多,导致模型的泛化能力较差。因此,在卷积层中使用多尺度卷积核可以有效避免过拟合。在上采样层的设计中,由于上采样导致了特征的缩小,因此会引入信息丢失的问题,需要考虑是否引入反卷积层或下采样层来增强特征的连续性。
softmax层输出的语义标签采用独热编码形式,该编码方式将每个像素分配到相应的语义类别上。由于每个像素只对应一个语义类别,所以需要使用独热编码形式才能达到良好的性能。
3.2 训练阶段
语义分割模型的训练过程有以下几个步骤:
- 准备数据集:首先,需要准备好语义分割任务的数据集,通常包括图像数据、标签数据和训练集、验证集等。
- 数据预处理:对于语义分割任务,数据预处理通常包括图像归一化、裁剪、翻转、归一化等。
- 建立模型:接着,需要选择一个适合语义分割任务的特征提取器和分割头,并按照需求修改它们的超参数。
- 训练模型:首先,需要定义损失函数,最常用的是Dice系数损失函数,即两者IoU的负值。然后,使用一定的优化器和学习速率进行模型训练。
- 评估模型:使用验证集对模型进行评估,计算指标如Dice系数、F1-score等,分析模型表现,调优超参数。
- 测试模型:将测试集的数据输入模型进行测试,得到测试结果,分析模型表现,提出改进建议。
3.3 优化技巧
3.3.1 训练策略
语义分割模型的训练策略包括数据扩充、损失函数、正则化项、学习率衰减等。数据扩充是为了扩大数据集的规模,增加模型的鲁棒性。损失函数是衡量模型预测结果和真实标记之间差距的指标。正则化项是为了防止过拟合而添加的项,常用的有L2正则化、Dropout正则化等。学习率衰减是为了防止模型陷入局部最小值,提升模型收敛速度。
3.3.2 训练加速技巧
语义分割模型的训练往往十分耗时,需要通过各种手段加速模型的训练过程。例如,数据预处理时可以使用多线程提高IO读取效率;使用GPU进行训练可以显著加快训练速度;采用高效的优化算法可以极大地提升模型的训练速度和精度。除此之外,还可以采用在线混合精度训练的方式,同时训练浮点和半精度模型,并动态调整模型权重。
3.3.3 监督学习目标函数
语义分割模型常用的损失函数包括Dice系数损失函数、交叉熵损失函数、FOCAL损失函数等。Dice系数损失函数是Dice系数的反向传播,而交叉熵损失函数和FOCAL损失函数均是常用的损失函数。Dice系数是指真阳性与假阳性之比,其计算公式如下:
$DICE = \frac{2|Y\cap X|}{|X|+|Y|}$
其中,Y为真实标记,X为预测标记。当两者相等时,Dice系数等于1;当两者完全不匹配时,Dice系数等于0。
交叉熵损失函数是指预测结果与真实标记之间欧氏距离的平均损失,公式如下:
$loss=-\frac{1}{N}\sum_{i=1}^{N}(y_ilog(p_i)+(1-y_i)(log(1-p_i)))$
其中,$y_i$和$p_i$分别表示第i个样本的真实标记和预测概率,N表示样本数目。
FOCAL损失函数是在交叉熵损失函数的基础上,加入真阳性的权重,鼓励模型更关注困难样本,降低对易于区分的样本的关注度,提升模型的鲁棒性。公式如下:
$loss=-\alpha (1-p)^{\gamma} log(\sigma(\beta y p)) - (1-\alpha)((1-p)^{\gamma}) log((1-\sigma(\beta (1-y) (1-p))))$
$\alpha$是加权系数,决定模型对真阳性的关注度,其值越高,模型越倾向于关注真阳性样本。$\gamma$是Focal Loss的调制因子,控制正负样本的比例。$\beta$是仿射因子,控制样本的置信度得分,其值越大,模型越倾向于对样本赋予更大的置信度。
3.4 数据集准备
语义分割任务的数据集通常有两种形式,一种是已经划分好数据的标准数据集,另外一种是原始数据集,需要根据要求手动划分数据集。
对于已划分好的标准数据集,比如ADE20K、Cityscapes、CamVid、COCO-Stuff等,可以直接下载,并且已经划分好训练集、验证集和测试集。对于原始数据集,需要根据实际情况进行划分。通常来说,训练集和验证集的划分比例设置为7:3,测试集没有划分,使用全部的原始图像。
数据集的准备工作包括:
- 收集数据:将语义分割任务的数据集存储在文件夹中,并按照要求命名文件名。
- 划分数据集:将原始数据集按照一定比例划分训练集、验证集和测试集。
- 对训练集进行数据增强:对训练集进行数据增强,包括裁剪、翻转、旋转等方式,增加数据集的多样性。
- 对数据进行归一化:对图像数据进行归一化,使模型更容易收敛。
3.5 网络结构选型
由于语义分割任务具有高度的空间冗余特性,因此,模型在进行特征提取时,需要尽量保留图像的全局信息。在网络架构设计中,通常会使用较多的卷积层、上采样层和金字塔池化层,并配以合适的结构。
卷积层通常采用多种尺度的卷积核,如不同尺度的3x3、5x5、7x7卷积核,或者跨越不同尺度的卷积核。对于不同尺度的卷积核,有时可以得到更好的效果,有时需要根据资源和模型大小进行权衡。通常情况下,使用深度可分离卷积层(depthwise separable convolutions, DSC)可以获得更好的效果。DSC的优点是可以提高模型的空间分辨率,并减少参数数量。
上采样层用于提升特征的分辨率,通常采用步长为2的最大池化或反卷积层。由于模型的感受野大小限制,因此,不同尺度的池化层、反卷积层、上采样层都需要进行权衡。上采样层的选择需要满足空间连续性的要求,即不会出现裂缝和歧义。
金字塔池化层是一种特定的池化层,它将不同尺度的特征聚合到一起,以提升模型的感受野范围。对于语义分割任务,通常会采用不同尺度的金字塔池化层,提升模型的感受野大小和分辨力。但是,不同尺度的金字塔池化层会产生额外的计算开销,所以,需要根据资源和模型大小进行权衡。
3.6 混合精度训练
通过在线混合精度训练的方法,可以在保持模型准确率的情况下,提升模型训练速度和内存占用。在线混合精度训练的基本思想是训练模型的部分层为浮点型,部分层为半精度型。为了避免模型出现饱和现象,半精度数据需要参与梯度更新的次数要远远小于浮点型数据。在训练过程中,可以将模型的部分层设置为浮点型,其余层设置为半精度型。随着训练的进行,模型逐渐切换到半精度模式,直至模型完全转化为半精度模式。在每个批次中,浮点型和半精度型层的参数更新周期一致。在线混合精度训练可以有效地提升模型的训练速度,并减少内存占用,但它不是银弹,可能会引入额外的噪声,导致模型的准确率下降。因此,在确定不需要进行精度提升的任务时,可以关闭混合精度训练。
4.具体代码实例和解释说明
作者不可能把所有知识都亲手实现出来,但是我可以总结一些实现语义分割模型时的常见套路。
4.1 数据读取与预处理
import os
from PIL import Image
import numpy as np
import torch
import torchvision.transforms as transforms
class CityscapeDataset(torch.utils.data.Dataset):
def __init__(self, data_dir, img_transform=None, mask_transform=None):
self.img_path = [os.path.join(data_dir, 'leftImg8bit', x) for x in sorted(os.listdir(os.path.join(data_dir, 'leftImg8bit')))]
self.mask_path = [os.path.join(data_dir, 'gtFine', x) for x in sorted(os.listdir(os.path.join(data_dir, 'gtFine')))]
if len(self.img_path)!=len(self.mask_path):
raise ValueError('Image and Mask Dataset have different number of samples')
# image preprocessing
if not img_transform:
self.img_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
else:
self.img_transform = img_transform
# label preprocessing
if not mask_transform:
self.mask_transform = transforms.Compose([
transforms.ToTensor()
])
else:
self.mask_transform = mask_transform
def __getitem__(self, index):
""" Returns the transformed images along with their corresponding labels"""
img_name = self.img_path[index].split('/')[-1]
img_path = os.path.join(self.img_path[index], img_name)
mask_path = os.path.join(self.mask_path[index], 'color', mask_name)
img = Image.open(img_path).convert("RGB")
mask = Image.open(mask_path)
img = self.img_transform(img)
mask = self.mask_transform(mask)
return img, mask
def __len__(self):
return len(self.img_path)
Cityscape数据集的格式比较特殊,与其他数据集不同,其训练集、验证集、测试集的文件夹不同,而且有多个标签文件夹,因此需要进行相应的处理。我们定义了一个CityscapeDataset类,继承了torch.utils.data.Dataset
,实现了初始化函数 __init__()
和getitem函数 .__getitem__()
。
初始化函数 __init__()
中的第一行代码 self.img_path = [os.path.join(data_dir, 'leftImg8bit', x) for x in sorted(os.listdir(os.path.join(data_dir, 'leftImg8bit')))]
和第二行代码 self.mask_path = [os.path.join(data_dir, 'gtFine', x) for x in sorted(os.listdir(os.path.join(data_dir, 'gtFine')))]
,分别找到数据集文件夹下的所有训练图像路径和标签路径。第二行代码中,sorted(os.listdir())
函数按照文件夹名排序,保证每次遍历顺序相同。
初始化函数 __init__()
的第三行代码和第四行代码,分别定义图像预处理和标签预处理的方法。图像预处理使用 torchvision.transforms
来实现,对RGB图像进行归一化和转换为PyTorch张量。标签预处理只是简单地转换为PyTorch张量。
getitem函数 .__getitem__()
,返回单个样本,包括图像和标签。首先,函数获取图像路径和标签路径,然后打开图像和标签文件,进行预处理,并返回经过预处理的图像和标签。
这里的预处理仅仅是图像预处理,标签预处理可以使用不同的转换规则。
4.2 模型定义与训练
import torch
import torch.nn as nn
import torchvision.models as models
from torchsummary import summary
class ResNetUNet(nn.Module):
def __init__(self, num_classes):
super().__init__()
self.resnet = models.resnet18(pretrained=True)
self.conv1 = self.resnet.conv1
self.bn1 = self.resnet.bn1
self.relu = self.resnet.relu
self.maxpool = self.resnet.maxpool
self.upsample = nn.ConvTranspose2d(512, 512, kernel_size=2, stride=2)
self.conv2 = nn.Sequential(*list(self.resnet.children())[5:-2])
self.out = nn.Conv2d(in_channels=64, out_channels=num_classes, kernel_size=1)
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.maxpool(x)
x = self.conv2(x)
x = self.upsample(x)
x = self.relu(x)
x = self.out(x)
return x
model = ResNetUNet(num_classes=19)
device = "cuda" if torch.cuda.is_available() else "cpu"
print(device)
if device == "cuda":
model = nn.DataParallel(model)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(params=model.parameters(), lr=0.001)
dataset_train = CityscapeDataset('/home/xxx/datasets/cityscape/',
img_transform=transforms.Compose([
transforms.Resize((224, 224)),
transforms.RandomHorizontalFlip(p=0.5),
transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
]),
mask_transform=transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor()])
)
dataloader_train = torch.utils.data.DataLoader(dataset_train, batch_size=16, shuffle=True, num_workers=4)
for epoch in range(10):
print("Epoch:", epoch+1)
running_loss = 0.0
total_samples = 0
correct_predictions = 0
for i, (imgs, masks) in enumerate(dataloader_train):
optimizer.zero_grad()
imgs, masks = imgs.to(device), masks.long().to(device)
outputs = model(imgs)
loss = criterion(outputs, masks)
loss.backward()
optimizer.step()
_, predicted = torch.max(outputs.data, 1)
correct_predictions += (predicted == masks).sum().item()
running_loss += loss.item()*masks.size(0)
total_samples += masks.size(0)
avg_loss = running_loss / float(total_samples)
accuracy = correct_predictions / float(total_samples) * 100
print("[%d/%d] loss: %.4f acc: %.4f" % (epoch + 1, 10, avg_loss, accuracy))
state_dict = model.module.state_dict() if isinstance(model, nn.DataParallel) else model.state_dict()
torch.save({
'epoch': epoch,
'model_state_dict': state_dict,
'optimizer_state_dict': optimizer.state_dict(),
}, '/content/drive/MyDrive/model_saved.pth')
上面展示的是语义分割模型的一个实现。
首先,我们导入了一些必要的包,包括PyTorch、卷积神经网络、优化器等。
然后,我们定义了我们的模型,这里我们采用了ResNet-18作为特征提取器,在顶层用一个1x1卷积层将其输出映射为指定类别数。我们也可以使用别的特征提取器,比如DenseNet或EfficientNet。
随后,我们定义了损失函数为交叉熵损失函数,优化器为Adam优化器。
接着,我们加载Cityscape数据集,并构造了DataLoader。我们将数据集图像、标签转换为PyTorch张量,并将它们送入到模型中。
在训练循环中,我们按批次读取数据,并使用优化器进行训练。我们计算每个批次的损失值、正确预测值,并打印它们。我们使用torch.nn.functional.cross_entropy
计算交叉熵损失值。随后,我们保存训练好的模型。
训练结束后,我们保存模型的参数。