赞
踩
DeepLab v3+ 模型被认为是语义分割的新高峰(2018年),因为这个模型的效果非常好。该论文主要在模型的架构上作文章,引入了可任意控制编码器提取特征的分辨率,通过膨胀卷积平衡精度和耗时。
这是一篇 2018 年发表在 CVPR 上的文章。相比 DeepLab V3 有五点变化:①Encoder-Decoder 架构;②改进的 ASPP 模块;③更强的多尺度信息融合;④Xception 作为 Backbone;⑤支持任意大小输入。因此,DeepLab v3+ 在架构、模块改进和特性方面都相比于 DeepLab v3 有所提升,使得其在语义分割任务上表现更为优秀。
Encoder-Decoder 架构:DeepLab v3+ 引入了一种新的 Encoder-Decoder 架构,将 DeepLab v3 的 ASPP(Atrous Spatial Pyramid Pooling)模块与一个特定的 decoder 模块相结合。这样的架构能够有效地整合全局信息和局部信息,从而提高语义分割的精度。
改进的 ASPP 模块:ASPP 在 DeepLab v3+ 中进行了改进。ASPP 的目标是捕获不同尺度下的上下文信息,以更好地理解图像中的物体。DeepLab v3+ 采用了更多的 atrous convolution(膨胀卷积)比例,使得模型能够更好地处理多尺度信息。
更强的多尺度信息融合:为了进一步提高语义分割的性能,DeepLab v3+ 使用了一种称为深度监督的技术,该技术可以在不同层次的网络中添加损失函数,使得低层次的特征也能参与监督,从而有助于更好地融合多尺度信息。
Xception 作为 Backbone:DeepLab v3+ 使用 Xception(一种更加高效的卷积神经网络架构)作为其 backbone,相比于 DeepLab v3 使用的 ResNet,Xception 具有更少的参数量和更高的计算效率。
支持任意大小输入:DeepLab v3+ 通过平均池化策略来处理不同尺寸的输入图像,因此可以接受任意大小的图像进行语义分割任务,而不受固定输入尺寸的限制。
Xception 是由 François Chollet 在 2016 年提出的卷积神经网络架构。它是 Google Inception 系列网络的进一步发展,通过采用深度可分离卷积(Depth-wise Separable Convolution)来降低参数量和计算复杂度,并在 ImageNet 图像分类任务上取得了很好的性能表现。Xception 的全称是 “Extreme Inception”,它的名字中的 “X” 取自 “Extreme”,意味着在 Inception 系列的基础上进行了极端改进。
Xception 的主要特点是在 Inception 模块中将传统的标准卷积替换为深度可分离卷积。深度可分离卷积将标准卷积拆分为两个步骤:首先是深度卷积(Depth-wise Convolution,DW Conv),对每个输入通道进行独立的卷积,然后是逐点卷积(Point-wise Convolution,PW Conv),用 1 × 1 1\times 1 1×1 卷积核进行通道间的线性组合。这种结构减少了计算量和参数量,同时在保持较高性能的情况下提高了计算效率。
Xception 在计算机视觉任务中取得了一定的成功,特别是在图像分类任务上。它的设计思想也对后续的一些网络架构产生了影响。
DeepLab v3+ 的核心思想是通过引入一种新的 Encoder-Decoder(编码解码器)架构来提高语义图像分割的性能。传统的 DeepLab 系列模型采用了 Atrous Convolution(膨胀卷积)和 ASPP(Atrous Spatial Pyramid Pooling,空洞空间金字塔池化)等技术来捕捉图像的上下文信息,以便更好地理解图像中的物体。然而,在处理多尺度信息和边缘部分时,这些模型可能存在一定的限制。
为了解决这些问题,DeepLab v3+ 引入了一个 Encoder-Decoder 结构,该结构由以下几个要点组成:
Encoder:采用了深度可分离卷积(Depth-wise Separable Convolution)的 Xception 网络作为 Encoder 的主干网络。Xception 是一种高效的卷积神经网络架构,它比传统的 ResNet 有更少的参数量和更高的计算效率。
ASPP:在 Encoder 的末尾,引入了 Atrous Spatial Pyramid Pooling (ASPP) 模块。ASPP 可以捕获不同尺度下的上下文信息,从而更好地理解图像的语义信息。
Decoder:DeepLab v3+ 使用了一个特定的 decoder 模块,该模块通过上采样操作将 Encoder 提取的特征图恢复到原始输入图像的大小。这样做的目的是整合全局信息和局部信息,使得模型能够更好地进行语义分割。
深度监督:DeepLab v3+ 还引入了深度监督的技术。在不同层次的 decoder 中添加损失函数,使得低层次的特征也能参与监督。这样可以有助于更好地融合多尺度信息,提高模型的性能。
支持任意大小输入:DeepLab v3+ 通过平均池化策略来处理不同尺寸的输入图像,因此可以接受任意大小的图像进行语义分割任务,而不受固定输入尺寸的限制。
综上所述,DeepLab v3+ 的核心思想是通过 Encoder-Decoder 结构和其他改进措施,更好地捕获图像的上下文信息和多尺度信息,从而在语义图像分割任务上取得更好的性能。
DCNN 在语义分割任务中常使用 ASPP 模块或 Encoder-Decoder(编码-解码)结构。前者通过在多个尺度和多个有效感受野上用卷积或池化操作来编码(encoder)多尺度的上下文信息,而后者则通过逐渐恢复空间信息来捕捉更清晰的物体边界。在本文中,我们提出将这两种方法的优势相结合。具体地,我们提出的模型 DeepLab v3+ 在 DeepLab v3 的基础上添加了一个简单而有效的解码器模块(Decoder),以改进分割结果,特别是物体边界的分割结果。我们进一步探索了 Xception 模型,并将深度可分离卷积应用于 ASPP 和解码器(Decoder)模块,从而实现了一个更快更强大的编码-解码(Encoder-Decoder)网络。我们在 PASCAL VOC 2012 和 Cityscapes 数据集上验证了所提出模型的有效性,测试集的性能分别达到了 89.0 % 89.0\% 89.0% 和 82.1 % 82.1\% 82.1%,且无需任何后处理。我们的论文还附带了在 Tensorflow 中公开提供的所提出模型的参考实现,链接为:官方代码(TensorFlow版)。
DeepLab v3+ 在论文中采用的是 Xception 系列作为主干特征提取网络,Bubbliiiing 提供了两个主干网络,分别是 Xception 和 MobileNet v2。由于 Xception 的训练成本相对较高,因此本文以 MobileNet v2 为 Backbone,接下来简单介绍一下 MobileNet v2。
- DeepLab v3+ 在 Encoder 部分引入了大量的膨胀卷积(在 ASPP 模块中),这使得模型可以在不损失信息的情况下增加感受野,让每个卷积输出都包含较大范围的信息。
- 原论文中使用的 Backbone 为 Xception。
MobileNet v2 是由 Google 在 2018 年提出的一种高效的卷积神经网络架构,用于在计算资源受限的设备上进行图像分类和相关视觉任务。它是 MobileNet 系列的第二代,是对 MobileNet v1 的改进和扩展。
MobileNet v2 的核心思想是通过一系列的创新设计来提高模型的性能和计算效率。主要的改进包括:
线性 Bottleneck 结构:MobileNet v2 引入了线性 Bottleneck 结构,用于构建深层网络。这个结构包括一个 1x1 卷积用于降维,然后是一系列的 Depthwise Separable Convolution(深度可分离卷积)层,最后再使用 1 × 1 1\times 1 1×1 卷积进行升维。这个结构在增加了网络深度的同时,减少了参数量和计算量。
逆残差结构:MobileNet v2 引入了逆残差结构,用于解决在深层网络中的梯度消失问题。这个结构在某些层中允许跳过连接,使得梯度能够更好地在网络中传播,有助于训练更深的网络。
线性激活函数:MobileNet v2 使用线性激活函数代替了传统的非线性激活函数(如 ReLU),这样可以避免在梯度传播过程中的信息损失,有助于训练更深的网络。
更宽的网络:MobileNet v2 使用了更宽的网络(即更多的通道数),以增加模型的表达能力和准确性。
SE(Squeeze-and-Excitation)模块:MobileNet v2 引入了 SE 模块,用于增强网络对重要特征的关注程度。SE 模块通过自适应地调整通道之间的重要性,从而提高模型的性能。
MobileNet v2 在 ImageNet 图像分类任务上取得了优秀的性能,同时具有更高的计算效率,适合于部署在资源受限的移动设备和嵌入式系统上。由于其出色的性能和高效的计算特性,MobileNet v2 成为了移动端计算机视觉任务中的重要选择。
MobileNet v2 详细介绍请见博客:MobileNet系列 (v1 ~ v3) 理论讲解
我们看一下 MobileNet v2 PyTorch官方实现的代码:
class ConvBNReLU(nn.Sequential): def __init__(self, in_channel, out_channel, kernel_size=3, stride=1, groups=1): padding = (kernel_size - 1) // 2 super(ConvBNReLU, self).__init__( nn.Conv2d(in_channel, out_channel, kernel_size, stride, padding, groups=groups, bias=False), nn.BatchNorm2d(out_channel), nn.ReLU6(inplace=True) ) class InvertedResidual(nn.Module): def __init__(self, in_channel, out_channel, stride, expand_ratio): super(InvertedResidual, self).__init__() hidden_channel = in_channel * expand_ratio self.use_shortcut = stride == 1 and in_channel == out_channel layers = [] if expand_ratio != 1: # 1x1 pointwise conv layers.append(ConvBNReLU(in_channel, hidden_channel, kernel_size=1)) layers.extend([ # 3x3 depthwise conv ConvBNReLU(hidden_channel, hidden_channel, stride=stride, groups=hidden_channel), # 1x1 pointwise conv(linear) nn.Conv2d(hidden_channel, out_channel, kernel_size=1, bias=False), nn.BatchNorm2d(out_channel), ]) self.conv = nn.Sequential(*layers) def forward(self, x): if self.use_shortcut: return x + self.conv(x) else: return self.conv(x)
逆残差结构可以分为两个部分:
需要注意的是,在 DeepLab v3+ 中,一般不会进行 5 次下采样(下采样倍率越大,分割模型效果越差),可选的有 3 次下采样( 2 3 = 8 2^3 = 8 23=8 倍下采样)和 4 次下采样( 2 4 = 16 2^4 = 16 24=16 倍下采样),本文使用的 4 次下采样,即 16 倍下采样。
在完成 MobileNet v2(Backbone)的特征提取后,我们可以获得两个有效特征层,一个有效特征层是输入图片高和宽压缩两次(4 倍下采样)的结果(Low-Level Features,低语义特征图),一个有效特征层是输入图片高和宽压缩 4 次(16 倍下采样)的结果。
我们可以很容易获取到 PyTorch 官方实现的 MobileNet v2 模型,但是我们前面说过,DeepLab v3+ 并不会使用全部的 Backbone,并且还有一条分支(提取出一个浅层的特征图),所以我们需要对 MobileNet v2 的Backbone 进行修改。如何修改是一个问题,最好的状态就是不修改对应的源码,因为我们以后可能还会使用 MobileNet v2 原始的 Backbone,所以我们可以对读取到的 MobileNet v2 进行修改。
修改的依据就是在 PyTorch 中,模型就是一个字典。
文件名称:deeplabv3_plus.py
。
from nets.mobilenetv2 import mobilenetv2 class MobileNetV2(nn.Module): def __init__(self, downsample_factor=8, pretrained=True): super(MobileNetV2, self).__init__() # 导入 partial from functools import partial # 定义好模型 model = mobilenetv2(pretrained) # 去除逆残差结构后面的1×1卷积 self.features = model.features[:-1] self.total_idx = len(self.features) # Block的数量 self.down_idx = [2, 4, 7, 14] # 每个进行下采样逆残差结构在模型中所处的idx if downsample_factor == 8: """ 如果下采样倍数为8,则先对倒数两个需要下采样的Block进行参数修改,使其stride=1、dilate=2 如果下采样倍数为16,则先对最后一个Block进行参数修改,使其stride=1、dilate=2 """ # 修改倒数两个Block for i in range(self.down_idx[-2], self.down_idx[-1]): self.features[i].apply( partial(self._nostride_dilate, dilate=2)) # 修改stride=1, dilate=2 # 修改剩下的所有block,使其都使用膨胀卷积 for i in range(self.down_idx[-1], self.total_idx): self.features[i].apply( partial(self._nostride_dilate, dilate=4)) elif downsample_factor == 16: for i in range(self.down_idx[-1], self.total_idx): self.features[i].apply( partial(self._nostride_dilate, dilate=2) ) def _nostride_dilate(self, m, dilate): """修改返回的block,使其stride=1 Args: m (str): 模块的名称 dilate (int): 膨胀系数 """ classname = m.__class__.__name__ # 获取模块名称 if classname.find('Conv') != -1: # 如果有卷积层 if m.stride == (2, 2): # 如果卷积层的步长为2 m.stride = (1, 1) # 修改步长为1 if m.kernel_size == (3, 3): # 修改对应的膨胀系数和padding m.dilation = (dilate//2, dilate//2) m.padding = (dilate//2, dilate//2) else: # 如果卷积层步长本来就是1 if m.kernel_size == (3, 3): # 修改对应的膨胀系数和padding m.dilation = (dilate, dilate) m.padding = (dilate, dilate) def forward(self, x): """前向推理 Args: x (tensor): 输入特征图 Returns: (tensor, tensor): 输出特征图(两个) """ low_level_features = self.features[:4](x) # 浅层的特征图 x = self.features[4:](low_level_features) # 经过完整Backbone的特征图 return low_level_features, x
这里我们需要对 partial
这个 Python 内置的方法进行说明:
from functools import partial
# 原始函数
def add(x, y):
return x + y
# 使用 partial 部分应用 add 函数的第一个参数为 5
add_5 = partial(add, 5)
# 调用新的函数 add_5 只需要提供第二个参数
result = add_5(10) # 实际调用 add(5, 10)
print(result) # 输出: 15
在上面的例子中,partial
函数将 add
函数的第一个参数固定为 5,然后返回一个新的函数 add_5
。当我们调用 add_5(10)
时,它实际上等同于调用 add(5, 10)
,返回结果为 15。
使用 parital
函数在某些情况下可以使代码更加简洁和易读,并且使得函数的复用更加方便。
在 DeepLab v3+ 中,加强特征提取网络可以分为两部分:
concat
;最后再进行 2 次普通卷积。文件名称:deeplabv3_plus.py
。
class ASPP(nn.Module): def __init__(self, dim_in, dim_out, rate=1, bn_mom=0.1): super(ASPP, self).__init__() self.branch1 = nn.Sequential( # 1×1 普通卷积 nn.Conv2d(dim_in, dim_out, 1, 1, padding=0, dilation=rate, bias=True), nn.BatchNorm2d(dim_out, momentum=bn_mom), nn.ReLU(inplace=True), ) self.branch2 = nn.Sequential( # 3×3膨胀卷积(r=6) nn.Conv2d(dim_in, dim_out, 3, 1, padding=6 * rate, dilation=6*rate, bias=True), nn.BatchNorm2d(dim_out, momentum=bn_mom), nn.ReLU(inplace=True), ) self.branch3 = nn.Sequential( # 3×3膨胀卷积(r=12) nn.Conv2d(dim_in, dim_out, 3, 1, padding=12 * rate, dilation=12*rate, bias=True), nn.BatchNorm2d(dim_out, momentum=bn_mom), nn.ReLU(inplace=True), ) self.branch4 = nn.Sequential( # 3×3膨胀卷积(r=18) nn.Conv2d(dim_in, dim_out, 3, 1, padding=18 * rate, dilation=18*rate, bias=True), nn.BatchNorm2d(dim_out, momentum=bn_mom), nn.ReLU(inplace=True), ) """ 在论文中,这里应该是池化层,但这里定义为普通卷积层, 但莫慌,在forward函数中先进行池化再进行卷积(相当于增加了一个后置卷积) """ self.branch5_conv = nn.Conv2d(dim_in, dim_out, 1, 1, 0, bias=True) self.branch5_bn = nn.BatchNorm2d(dim_out, momentum=bn_mom) self.branch5_relu = nn.ReLU(inplace=True) self.conv_cat = nn.Sequential( # concat之后需要用到的1×1卷积 nn.Conv2d(dim_out*5, dim_out, 1, 1, padding=0, bias=True), nn.BatchNorm2d(dim_out, momentum=bn_mom), nn.ReLU(inplace=True), ) def forward(self, x): [b, c, row, col] = x.size() # [BS, C, H, W] # 先进行前4个分支 conv1x1 = self.branch1(x) conv3x3_1 = self.branch2(x) conv3x3_2 = self.branch3(x) conv3x3_3 = self.branch4(x) # 第五个分支:全局平均池化+卷积 global_feature = torch.mean(input=x, dim=2, keepdim=True) # 沿着H进行mean global_feature = torch.mean(input=global_feature, dim=3, keepdim=True) # 再沿着W进行mean # 经典汉堡包卷积结构 global_feature = self.branch5_conv(global_feature) global_feature = self.branch5_bn(global_feature) global_feature = self.branch5_relu(global_feature) # 双线性插值使其回复到输入特征图的shape global_feature = F.interpolate( input=global_feature, size=(row, col), scale_factor=None, mode='bilinear', align_corners=True) # 沿通道方向将五个分支的内容堆叠起来 feature_cat = torch.cat( [conv1x1, conv3x3_1, conv3x3_2, conv3x3_3, global_feature], dim=1) # 最后经过1×1卷积调整通道数 result = self.conv_cat(feature_cat) return result
这里的
result
得到的就是图中绿色的特征图。
文件名称:deeplabv3_plus.py
。
class DeepLab(nn.Module): def __init__(self, num_classes, backbone="mobilenet", pretrained=True, downsample_factor=16): super(DeepLab, self).__init__() if backbone == "xception": """ 获得两个特征层 1. 浅层特征 [128,128,256] 2. 主干部分 [30,30,2048] """ self.backbone = xception( downsample_factor=downsample_factor, pretrained=pretrained) in_channels = 2048 low_level_channels = 256 elif backbone == "mobilenet": """ 获得两个特征层 1. 浅层特征 [128,128,24 2. 主干部分 [30,30,320] """ self.backbone = MobileNetV2( downsample_factor=downsample_factor, pretrained=pretrained) in_channels = 320 low_level_channels = 24 else: raise ValueError( 'Unsupported backbone - `{}`, Use mobilenet, xception.'.format(backbone)) # ASPP特征提取模块:利用不同膨胀率的膨胀卷积进行特征提取 self.aspp = ASPP(dim_in=in_channels, dim_out=256, rate=16//downsample_factor) # 浅层特征图 self.shortcut_conv = nn.Sequential( nn.Conv2d(low_level_channels, 48, 1), nn.BatchNorm2d(48), nn.ReLU(inplace=True) ) self.cat_conv = nn.Sequential( nn.Conv2d(48+256, 256, 3, stride=1, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Conv2d(256, 256, 3, stride=1, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.Dropout(0.1), ) # 最后的1×1卷积,目的是调整输出特征图的通道数,调整为 num_classes self.cls_conv = nn.Conv2d(256, num_classes, 1, stride=1) def forward(self, x): H, W = x.size(2), x.size(3) low_level_features, x = self.backbone(x) # 浅层特征-进行卷积处理 x = self.aspp(x) # 主干部分-利用ASPP结构进行加强特征提取 # 先利用1×1卷积对浅层特征图进行通道数的调整 low_level_features = self.shortcut_conv(low_level_features) # 先对深层特征图进行上采样 x = F.interpolate(x, size=(low_level_features.size( 2), low_level_features.size(3)), mode='bilinear', align_corners=True) # 再进行堆叠 x = self.cat_conv(torch.cat((x, low_level_features), dim=1)) # 最后使用3×3卷积进行特征提取 x = self.cls_conv(x) # 上采样得到和原图一样大小的特征图 x = F.interpolate(x, size=(H, W), mode='bilinear', align_corners=True) return x
利用 2.1 和 2.2 中得到的结果,我们可以获取输入图片的特征,此时,我们需要利用特征获得预测结果。
利用特征获得预测结果的过程可以分为 2 步:
num_classes
;interpolate
函数进行上采样使得最终输出层的 [H, W]
和输入图片一样。其代码实现已在 2.2.2 中给出。
DeepLab v3+ 所使用的损失函数由两部分组成:
Cross Entropy Loss 就是普通的交叉熵损失,当语义分割平台利用 Softmax 对像素点进行分类的时候使用。
交叉熵损失函数(Cross Entropy Loss)用于度量两个概率分布之间的差异。在深度学习中,通常用于多分类任务,特别是像素级分类、图像分类等任务。
假设有一个分类问题,模型的输出是一个概率分布 y ^ \hat{y} y^,表示每个类别的预测概率,而真实标签为 y y y,表示样本的真实类别。交叉熵损失函数的公式如下:
Cross Entropy Loss = − ∑ i = 1 N ∑ j = 1 C y i j log ( y ^ i j ) \text{Cross Entropy Loss} = -\sum_{i=1}^{N} \sum_{j=1}^{C} y_{ij} \log(\hat{y}_{ij}) Cross Entropy Loss=−i=1∑Nj=1∑Cyijlog(y^ij)
其中,
交叉熵损失函数的计算过程是,对每个样本计算其预测概率与真实标签的交叉熵,并将所有样本的交叉熵求和,然后取负值。该损失函数的目标是 最小化模型预测与真实标签之间的差异,使模型能够更好地拟合训练数据,并提高在未见过的数据上的泛化能力。
Dice Loss 将语义分割的评价指标作为 Loss,Dice 系数是一种集合相似度度量函数,通常用于计算两个样本的相似度,取值范围在 [ 0 , 1 ] [0,1] [0,1]。
当计算 Dice Loss 时,我们首先需要计算 Dice 系数,然后再将 Dice 系数取 1 减去得到 Dice Loss。
Dice = 2 ∣ X ∩ Y ∣ ∣ X ∣ + ∣ Y ∣ \text{Dice} = \frac{2|X \cap Y|}{|X| + |Y|} Dice=∣X∣+∣Y∣2∣X∩Y∣
其中, X X X 表示预测结果的二值掩码(或分割区域), Y Y Y 表示真实结果的二值掩码。
Dice Loss = 1 − Dice \text{Dice Loss} = 1 - \text{Dice} Dice Loss=1−Dice
Dice Loss 的取值范围在 [ 0 , 1 ] [0, 1] [0,1] 之间,越接近 0 表示预测结果和真实结果的相似度越高,损失越小,优化目标是将 Dice Loss 最小化,使得模型能够更好地拟合训练数据,提高在未见过的数据上的泛化能力。
训练结果预测需要用到两个文件:
deeplab.py
predict.py
我们首先需要去 deeplab.py
里面修改 model_path
以及 num_classes
,这两个参数必须要修改:
model_path
:指向训练好的权值文件,在 logs\
文件夹里num_classes
:指向检测类别的个数(需要+1(背景))完成修改后就可以运行 predict.py
进行检测了。运行后输入图片路径即可检测。
CUDA_VISIBLE_DEVICE=2,3 python predict
# 模型加载完毕后需要输入要预测图片的路径
DeepLab v3+ 在预测时主要包括两部分:
接下来我们先说明一下预处理部分。
文件名称:deeplab.py
。
def detect_image(self, image, count=False, name_classes=None): """图片推理 Args: image (_type_): 输入图片 count (bool, optional): _description_. Defaults to False. name_classes (_type_, optional): 类别墅. Defaults to None. Returns: _type_: 模型预测结果(单通道图) """ # 在这里将图像转换成RGB图像,防止灰度图在预测时报错。(代码仅仅支持RGB图像的预测,所有其它类型的图像都会转化成RGB) image = cvtColor(image) # 对输入图像进行一个备份,后面用于绘图 old_img = copy.deepcopy(image) orininal_h = np.array(image).shape[0] orininal_w = np.array(image).shape[1] # 给图像增加灰条,实现不失真的resize(也可以直接resize进行识别) # 裁剪后的图片, 缩放后的图像宽度, 缩放后的图像高度 image_data, nw, nh = resize_image(image, (self.input_shape[1], self.input_shape[0])) # 添加上batch_size维度 image_data = np.expand_dims(np.transpose(preprocess_input( np.array(image_data, np.float32)), (2, 0, 1)), 0)
以上就是前处理的过程,其中重点是 不失真的resize,其代码实现如下:
def resize_image(image, size): """将给定的图像进行调整大小并居中放置在新的画布上 Args: image (_type_): 输入图片 size (_type_): 目标大小 Returns: (elem1, elem2, elem3): (裁剪后的图片, 缩放后的图像宽度, 缩放后的图像高度) """ iw, ih = image.size # 获取图片大小 w, h = size # 获取目标大小 scale = min(w/iw, h/ih) # 计算了原始图像与目标大小之间的缩放比例。 nw = int(iw*scale) # 计算了缩放后的图像宽度 nh = int(ih*scale) # 计算了缩放后的图像高度 # 使用 Pillow 的 resize() 方法将图像调整为缩放后的大小。 # 使用 BICUBIC 插值方法进行图像的重采样,以获得更平滑的结果。 image = image.resize((nw,nh), Image.BICUBIC) # 创建了一个新的画布,用于放置调整后的图像。 # 画布大小与目标大小相同,并且以灰色 (128, 128, 128) 作为默认背景色。 new_image = Image.new('RGB', size, (128,128,128)) # 将调整后的图像粘贴到新的画布上。图像会居中放置在画布上 new_image.paste(image, ((w-nw)//2, (h-nh)//2)) return new_image, nw, nh
with torch.no_grad(): """ 在推理(inference)过程中关闭梯度计算,以节省内存并提高推理速度 """ images = torch.from_numpy(image_data) if self.cuda: images = images.cuda() # 图片传入网络进行预测 pr = self.net(images)[0] # 取出每一个像素点的种类 pr = F.softmax(pr.permute(1, 2, 0), dim=-1).cpu().numpy() # 将灰条部分截取掉 pr = pr[int((self.input_shape[0] - nh) // 2): int((self.input_shape[0] - nh) // 2 + nh), int((self.input_shape[1] - nw) // 2): int((self.input_shape[1] - nw) // 2 + nw)] # 进行图片的resize(普通的resize) pr = cv2.resize(pr, (orininal_w, orininal_h), interpolation=cv2.INTER_LINEAR) # 取出每一个像素点的种类 pr = pr.argmax(axis=-1) # 计数 if count: classes_nums = np.zeros([self.num_classes]) total_points_num = orininal_h * orininal_w print('-' * 63) print("|%25s | %15s | %15s|" % ("Key", "Value", "Ratio")) print('-' * 63) for i in range(self.num_classes): num = np.sum(pr == i) ratio = num / total_points_num * 100 if num > 0: print("|%25s | %15s | %14.2f%%|" % (str(name_classes[i]), str(num), ratio)) print('-' * 63) classes_nums[i] = num print("classes_nums:", classes_nums) if self.mix_type == 0: # seg_img = np.zeros((np.shape(pr)[0], np.shape(pr)[1], 3)) # for c in range(self.num_classes): # seg_img[:, :, 0] += ((pr[:, :] == c ) * self.colors[c][0]).astype('uint8') # seg_img[:, :, 1] += ((pr[:, :] == c ) * self.colors[c][1]).astype('uint8') # seg_img[:, :, 2] += ((pr[:, :] == c ) * self.colors[c][2]).astype('uint8') seg_img = np.reshape(np.array(self.colors, np.uint8)[ np.reshape(pr, [-1])], [orininal_h, orininal_w, -1]) # 将新图片转换成Image的形式 image = Image.fromarray(np.uint8(seg_img)) # 将新图与原图及进行混合 image = Image.blend(old_img, image, 0.7) elif self.mix_type == 1: # seg_img = np.zeros((np.shape(pr)[0], np.shape(pr)[1], 3)) # for c in range(self.num_classes): # seg_img[:, :, 0] += ((pr[:, :] == c ) * self.colors[c][0]).astype('uint8') # seg_img[:, :, 1] += ((pr[:, :] == c ) * self.colors[c][1]).astype('uint8') # seg_img[:, :, 2] += ((pr[:, :] == c ) * self.colors[c][2]).astype('uint8') seg_img = np.reshape(np.array(self.colors, np.uint8)[ np.reshape(pr, [-1])], [orininal_h, orininal_w, -1]) # 将新图片转换成Image的形式 image = Image.fromarray(np.uint8(seg_img)) elif self.mix_type == 2: seg_img = (np.expand_dims(pr != 0, -1) * np.array(old_img, np.float32)).astype('uint8') # 将新图片转换成Image的形式 image = Image.fromarray(np.uint8(seg_img)) return image
我们使用的训练文件均采用 PASCAL VOC 的格式。语义分割模型训练的文件分为两部分:
如下图所示。
原图就是普通的 RGB 图像,标签就是灰度图或者 8 位彩色图(
2
8
=
256
2^8 = 256
28=256,所以像素范围为
[
0
,
255
]
[0, 255]
[0,255])。原图的 shape 为 [height, width, 3]
,标签的 shape 就是 [height, width]
。对于标签而言,每个像素点的内容是一个数字,比如 0, 1, 2, 3, 4, 5, ...,
就代表这个像素点所属的类别。
语义分割的工作就是对原始的图片的每一个像素点进行分类,所以通过预测结果中每个像素点属于每个类别的概率与标签对比,可以对网络进行训练。
此外我们还需要注意的是❗️:标签文件是灰度图,只不过我们是用 P 模式打开的,不同灰度值的部分加了彩色,如果我们正常打开这个图片,那么效果如下:
是的,我们人眼很难通过像素值来分辨不同的类别(飞机的像素值为 1,人的像素值为 15),所以我们一般是使用 P 模式打开真实标签的。当然,对于计算机而言,我们可以根据像素值的大小对不同类别进行归类。
本文使用 PASCAL VOC 格式进行模型训练,训练前需要自己制作好数据集,如果没有自己的数据集,可以下载官方数据集。
PASCAL VOC 2012 数据集下载地址:Visual Object Classes Challenge 2012 (VOC2012)
PASCAL VOC 解压好之后,目录结构如下:
`-- VOCdevkit
`-- VOC2012
|-- Annotations
|-- ImageSets
| |-- Action
| |-- Layout
| |-- Main
| `-- Segmentation
|-- JPEGImages
|-- SegmentationClass
`-- SegmentationObject
训练前将 图片文件 放在 VOCdevkit/
文件夹下的 VOC2012/
文件夹下的 JPEGImages
中。
训练前将 标签文件 放在 VOCdevkit/
文件夹下的 VOC2012/
文件夹下的 SegmentationClass
中。
工具:labelme
pip install labelme
labelme # 打开labelme
Open Dir
:打开我们要标注文件所在文件夹Create Polygons
:开始标注多边形D
进入下一张图片;使用按键 A
进入上一张图片);途中需要保存 .json
文件的保存路径,我们确认好路径即可建议打开自动保存
File -> Save Automatically
标注好的数据会生成一个 .json
文件,内容如下所示:
{ "version": "5.2.1", # Labelme 的版本号 "flags": {}, # 用于存储一些标注时的标记或标志信息 "shapes": [ # 一个数组,包含图像中标注的物体的形状信息。每个元素表示一个物体的标注 { "label": "airplane", # 标注物体的类别名称 "points": [ # 这是一个数组,包含构成标注物体边界的点坐标。每个点坐标表示一个点的 x 和 y 坐标值 [ 198.94308943089433, 287.6422764227642 ], ... ... [ 331.4634146341464, 343.739837398374 ] ], "group_id": null, # 用于分组的标识符,通常在多个形状之间进行分组时使用 "description": "", # 对标注物体的描述信息 "shape_type": "polygon", # 标注物体形状的类型。在这个示例中,使用了 "polygon",表示标注的物体是由多边形组成的 "flags": {} # 用于存储关于标注物体的其他标记或标志信息 } ], "imagePath": "..\\Airplane_01.jpg", # 原始图像的文件路径 "imageData": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDA..... # 图像的数据,通常以 Base64 编码的形式包含在 JSON 中 "imageHeight": 720, # 原始图像的高度,以像素为单位 "imageWidth": 1280 # 这是原始图像的宽度,以像素为单位。 }
在手工标注得到 .json
文件后,我们需要进行转换。因为 PASCAL VOC 的 Ground Truth 是灰度图,不是 Json 文件。Json 转 灰度图代码如下:
import base64 import json import os import os.path as osp import numpy as np import PIL.Image from labelme import utils if __name__ == '__main__': jpgs_path = "datasets/JPEGImages" pngs_path = "datasets/SegmentationClass" # 需要注意的是要有一个背景类别 classes = ["_background_", "aeroplane", "bicycle", "bird", "boat", "bottle", "bus", "car", "cat", "chair", "cow", "diningtable", "dog", "horse", "motorbike", "person", "pottedplant", "sheep", "sofa", "train", "tvmonitor"] count = os.listdir("./datasets/before/") for i in range(0, len(count)): path = os.path.join("./datasets/before", count[i]) if os.path.isfile(path) and path.endswith('json'): data = json.load(open(path)) if data['imageData']: imageData = data['imageData'] else: imagePath = os.path.join( os.path.dirname(path), data['imagePath']) with open(imagePath, 'rb') as f: imageData = f.read() imageData = base64.b64encode(imageData).decode('utf-8') img = utils.img_b64_to_arr(imageData) label_name_to_value = {'_background_': 0} for shape in data['shapes']: label_name = shape['label'] if label_name in label_name_to_value: label_value = label_name_to_value[label_name] else: label_value = len(label_name_to_value) label_name_to_value[label_name] = label_value # label_values must be dense label_values, label_names = [], [] for ln, lv in sorted(label_name_to_value.items(), key=lambda x: x[1]): label_values.append(lv) label_names.append(ln) assert label_values == list(range(len(label_values))) lbl, _ = utils.shapes_to_label(img.shape, data['shapes'], label_name_to_value) lbl = lbl.astype(np.uint8) new = np.zeros_like(lbl, dtype=np.uint8) for name in label_names: index_json = label_names.index(name) index_all = classes.index(name) new += np.uint8(index_all) * np.uint8(lbl == index_json) utils.lblsave(osp.join(pngs_path, count[i].split(".")[0] + '.png'), new) print('Saved ' + count[i].split(".")[0] + '.jpg and ' + count[i].split(".")[0] + '.png')
- 原图的后缀是
.jpg
- 标签的后缀是
.png
结果演示:
在完成数据集的摆放之后,我们需要对数据集进行下一步的处理,目的是获得训练用的 train.txt
以及val.txt
,需要用到根目录下的 voc_annotation.py
。
如果训练 PASCAL VOC 原始数据集,则不需要运行下面脚本(数据集自带
train.txt
和val.txt
)。
import os import random import numpy as np from PIL import Image from tqdm import tqdm """ 想要增加测试集修改trainval_percent 修改train_percent用于改变验证集的比例 9:1 注意:当前该库将测试集当作验证集使用,不单独划分测试集 """ trainval_percent = 1 train_percent = 0.9 # 指向VOC数据集所在的文件夹(默认指向根目录下的VOC数据集) VOCdevkit_path = 'VOCdevkit' if __name__ == "__main__": random.seed(0) print("Generate txt in ImageSets.") segfilepath = os.path.join(VOCdevkit_path, 'VOC2012/SegmentationClass') saveBasePath = os.path.join(VOCdevkit_path, 'VOC2012/ImageSets/Segmentation') temp_seg = os.listdir(segfilepath) total_seg = [] for seg in temp_seg: if seg.endswith(".png"): total_seg.append(seg) num = len(total_seg) list = range(num) tv = int(num*trainval_percent) tr = int(tv*train_percent) trainval = random.sample(list, tv) train = random.sample(trainval, tr) print("train and val size", tv) print("traub suze", tr) ftrainval = open(os.path.join(saveBasePath, 'trainval.txt'), 'w') ftest = open(os.path.join(saveBasePath, 'test.txt'), 'w') ftrain = open(os.path.join(saveBasePath, 'train.txt'), 'w') fval = open(os.path.join(saveBasePath, 'val.txt'), 'w') for i in list: name = total_seg[i][:-4]+'\n' if i in trainval: ftrainval.write(name) if i in train: ftrain.write(name) else: fval.write(name) else: ftest.write(name) ftrainval.close() ftrain.close() fval.close() ftest.close() print("Generate txt in ImageSets done.") print("Check datasets format, this may take a while.") print("检查数据集格式是否符合要求,这可能需要一段时间。") classes_nums = np.zeros([256], np.int) for i in tqdm(list): name = total_seg[i] png_file_name = os.path.join(segfilepath, name) if not os.path.exists(png_file_name): raise ValueError("未检测到标签图片%s,请查看具体路径下文件是否存在以及后缀是否为png。" % (png_file_name)) png = np.array(Image.open(png_file_name), np.uint8) if len(np.shape(png)) > 2: print("标签图片%s的shape为%s,不属于灰度图或者八位彩图,请仔细检查数据集格式。" % (name, str(np.shape(png)))) print("标签图片需要为灰度图或者八位彩图,标签的每个像素点的值就是这个像素点所属的种类。" % (name, str(np.shape(png)))) classes_nums += np.bincount(np.reshape(png, [-1]), minlength=256) print("打印像素点的值与数量。") print('-' * 37) print("| %15s | %15s |" % ("Key", "Value")) print('-' * 37) for i in range(256): if classes_nums[i] > 0: print("| %15s | %15s |" % (str(i), str(classes_nums[i]))) print('-' * 37) if classes_nums[255] > 0 and classes_nums[0] > 0 and np.sum(classes_nums[1:255]) == 0: print("检测到标签中像素点的值仅包含0与255,数据格式有误。") print("二分类问题需要将标签修改为背景的像素点值为0,目标的像素点值为1。") elif classes_nums[0] > 0 and np.sum(classes_nums[1:]) == 0: print("检测到标签中仅仅包含背景像素点,数据格式有误,请仔细检查数据集格式。") print("JPEGImages中的图片应当为.jpg文件、SegmentationClass中的图片应当为.png文件。")
确保数据集文件夹中有 train.txt
和 val.txt
,此时我们可以开始训练了。
需要注意的是❗️:
num_classes
用于指向检测类别的个数 + 1(包含背景类)num_classes=21
除此之外在 train.py
文件夹下面,选择:
backbone
:使用 Xception 还是 MobileNet v2 作为 Backbonemodel_path
:预训练权重位置(要和主干模型相对应)downsample_factor
:下采样系数(可选值:8 或 16)
之后就可以开始训练了。
训练结果预测需要用到两个文件,分别是
deeplab.py
predict.py
与文中 3.1 无明显区别。
训练分为两个阶段:
冻结阶段:冻结阶段的目的是在训练过程中固定模型的主干部分(Backbone 不再更新参数),也就是特征提取网络部分,而只训练顶部的分类器(通常是全连接层或者卷积层)部分。冻结阶段通常用于初期训练,特别是在机器性能有限,显存较小或显卡性能较差的情况下。在冻结阶段,可以设置 Freeze_Epoch
等于 UnFreeze_Epoch
,此时仅仅进行冻结训练。
解冻阶段:解冻阶段是在冻结阶段之后进行的,此时模型的主干部分被解冻,即所有参数都可以进行更新。解冻阶段的目的是对整个模型进行微调,以适应特定任务的需求。在解冻阶段,可以设置适当的 UnFreeze_Epoch
来控制训练轮数(或迭代次数)。
总结来说,冻结阶段用于先训练分类器部分,解冻阶段用于对整个模型进行微调。Freeze_Epoch
和 UnFreeze_Epoch
等参数可以根据具体问题和实验情况进行设置。根据机器性能、显存情况以及训练数据的大小,可以调整这些超参数,以便在有限的资源下取得较好的训练结果。
Init_Epoch
:模型当前开始的训练 epoch,其值可以大于 Freeze_Epoch
Init_Epoch = 60、Freeze_Epoch = 50、UnFreeze_Epoch = 100
epoch=60
开始,并调整对应的学习率。Freeze_Epoch
:模型冻结训练的 Freeze_Epoch
(当 Freeze_Train=False
时失效)Freeze_batch_size
:模型冻结训练的 batch_size
(当 Freeze_Train=False
时失效)Adam 优化器:
# 冻结训练
Init_Epoch = 0,Freeze_Epoch = 50,UnFreeze_Epoch = 100,Freeze_Train = True,
optimizer_type = 'adam',Init_lr = 5e-4,weight_decay = 0
# 不冻结训练
Init_Epoch = 0,UnFreeze_Epoch = 100,Freeze_Train = False,
optimizer_type = 'adam',Init_lr = 5e-4,weight_decay = 0
SGD 优化器:
# 冻结训练
Init_Epoch = 0,Freeze_Epoch = 50,UnFreeze_Epoch = 100,Freeze_Train = True,
optimizer_type = 'sgd',Init_lr = 7e-3,weight_decay = 1e-4
# 不冻结训练
Init_Epoch = 0,UnFreeze_Epoch = 100,Freeze_Train = False,
optimizer_type = 'sgd',Init_lr = 7e-3,weight_decay = 1e-4
其中:
UnFreeze_Epoch
可以在100-300
之间调整。
Adam 优化器:
# 冻结训练
Init_Epoch = 0,Freeze_Epoch = 50,UnFreeze_Epoch = 100,Freeze_Train = True,
optimizer_type = 'adam',Init_lr = 5e-4,weight_decay = 0
# 不冻结训练
Init_Epoch = 0,UnFreeze_Epoch = 100,Freeze_Train = False,
optimizer_type = 'adam',Init_lr = 5e-4,weight_decay = 0
SGD 优化器:
# 冻结训练
Init_Epoch = 0,Freeze_Epoch = 50,UnFreeze_Epoch = 120,Freeze_Train = True,
optimizer_type = 'sgd',Init_lr = 7e-3,weight_decay = 1e-4
# 不冻结训练
Init_Epoch = 0,UnFreeze_Epoch = 120,Freeze_Train = False,
optimizer_type = 'sgd',Init_lr = 7e-3,weight_decay = 1e-4
其中:
UnFreeze_Epoch
可以在 120-300
之间调整。Adam
相较于 SGD
收敛的快一些。因此 UnFreeze_Epoch
理论上可以小一点,但依然推荐更多的 Epoch
。在显卡能够接受的范围内,越大越好。显存不足与数据集大小无关,提示显存不足请调小 batch_size。
注意❗️:
BatchNorm
层影响,batch_size
最小为 2
,不能为 1
。正常情况下 Freeze_batch_size
建议为 Unfreeze_batch_size
的 1 ~ 2 倍。不建议设置的差距过大,因为关系到学习率的自动调整。
===>background: Iou-93.08; Recall (equal to the PA)-97.11; Precision-95.74 ===>aeroplane: Iou-84.57; Recall (equal to the PA)-91.72; Precision-91.56 ===>bicycle: Iou-42.19; Recall (equal to the PA)-86.07; Precision-45.28 ===>bird: Iou-81.81; Recall (equal to the PA)-92.48; Precision-87.64 ===>boat: Iou-61.61; Recall (equal to the PA)-76.12; Precision-76.37 ===>bottle: Iou-71.61; Recall (equal to the PA)-88.54; Precision-78.93 ===>bus: Iou-93.45; Recall (equal to the PA)-95.97; Precision-97.27 ===>car: Iou-84.7; Recall (equal to the PA)-90.26; Precision-93.22 ===>cat: Iou-87.14; Recall (equal to the PA)-92.56; Precision-93.71 ===>chair: Iou-33.68; Recall (equal to the PA)-53.53; Precision-47.6 ===>cow: Iou-80.36; Recall (equal to the PA)-86.62; Precision-91.75 ===>diningtable: Iou-50.32; Recall (equal to the PA)-54.12; Precision-87.77 ===>dog: Iou-79.77; Recall (equal to the PA)-90.29; Precision-87.25 ===>horse: Iou-79.56; Recall (equal to the PA)-87.99; Precision-89.25 ===>motorbike: Iou-80.65; Recall (equal to the PA)-89.82; Precision-88.75 ===>person: Iou-80.07; Recall (equal to the PA)-86.52; Precision-91.49 ===>pottedplant: Iou-57.46; Recall (equal to the PA)-70.36; Precision-75.8 ===>sheep: Iou-80.42; Recall (equal to the PA)-89.93; Precision-88.37 ===>sofa: Iou-43.68; Recall (equal to the PA)-49.21; Precision-79.53 ===>train: Iou-84.46; Recall (equal to the PA)-89.14; Precision-94.14 ===>tvmonitor: Iou-67.93; Recall (equal to the PA)-74.5; Precision-88.52 ===> mIoU: 72.31; mPA: 82.52; Accuracy: 93.53 Get miou done. Save mIoU out to miou_out/mIoU.png Save mPA out to miou_out/mPA.png Save Recall out to miou_out/Recall.png Save Precision out to miou_out/Precision.png Save confusion_matrix out to miou_out/confusion_matrix.csv
Mean IoU(mIoU) 是语义分割任务中最常用的指标之一。它是所有类别的交并比(Intersection over Union,IoU)的平均值。对于每个类别,IoU 表示预测的区域与真实区域的交集面积除以它们的并集面积。mIoU 衡量模型在所有类别上的分割准确性,取值范围为 [ 0 , 1 ] [0, 1] [0,1],越接近 1 表示模型性能越好。
mPA(mean Pixel Accuracy) 是像素级准确率的平均值。像素级准确率是衡量模型对每个像素的预测准确率的指标。它是正确分类的像素数量与总像素数量的比例。mPA 衡量模型在整个数据集上的像素分类准确性。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。