当前位置:   article > 正文

[语义分割] DeepLab v3+(DeepLab v3 Plus、Backbone、Xception、MobileNet v2、Encoder、Decoder、ASPP、多尺度融合、膨胀卷积)_deeplabv3+

deeplabv3+
Encoder-Decoder with Atrous Separable Convolution for Semantic Image Segmentation

DeepLab v3+ 模型被认为是语义分割的新高峰(2018年),因为这个模型的效果非常好。该论文主要在模型的架构上作文章,引入了可任意控制编码器提取特征的分辨率,通过膨胀卷积平衡精度和耗时。

这是一篇 2018 年发表在 CVPR 上的文章。相比 DeepLab V3 有五点变化:①Encoder-Decoder 架构;②改进的 ASPP 模块;③更强的多尺度信息融合;④Xception 作为 Backbone;⑤支持任意大小输入。因此,DeepLab v3+ 在架构、模块改进和特性方面都相比于 DeepLab v3 有所提升,使得其在语义分割任务上表现更为优秀。

  1. Encoder-Decoder 架构:DeepLab v3+ 引入了一种新的 Encoder-Decoder 架构,将 DeepLab v3 的 ASPP(Atrous Spatial Pyramid Pooling)模块与一个特定的 decoder 模块相结合。这样的架构能够有效地整合全局信息和局部信息,从而提高语义分割的精度。

  2. 改进的 ASPP 模块:ASPP 在 DeepLab v3+ 中进行了改进。ASPP 的目标是捕获不同尺度下的上下文信息,以更好地理解图像中的物体。DeepLab v3+ 采用了更多的 atrous convolution(膨胀卷积)比例,使得模型能够更好地处理多尺度信息。

  3. 更强的多尺度信息融合:为了进一步提高语义分割的性能,DeepLab v3+ 使用了一种称为深度监督的技术,该技术可以在不同层次的网络中添加损失函数,使得低层次的特征也能参与监督,从而有助于更好地融合多尺度信息。

  4. Xception 作为 Backbone:DeepLab v3+ 使用 Xception(一种更加高效的卷积神经网络架构)作为其 backbone,相比于 DeepLab v3 使用的 ResNet,Xception 具有更少的参数量和更高的计算效率。

  5. 支持任意大小输入: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+ 的核心思想

DeepLab v3+ 的核心思想是通过引入一种新的 Encoder-Decoder(编码解码器)架构来提高语义图像分割的性能。传统的 DeepLab 系列模型采用了 Atrous Convolution(膨胀卷积)和 ASPP(Atrous Spatial Pyramid Pooling,空洞空间金字塔池化)等技术来捕捉图像的上下文信息,以便更好地理解图像中的物体。然而,在处理多尺度信息和边缘部分时,这些模型可能存在一定的限制

为了解决这些问题,DeepLab v3+ 引入了一个 Encoder-Decoder 结构,该结构由以下几个要点组成:

  1. Encoder:采用了深度可分离卷积(Depth-wise Separable Convolution)的 Xception 网络作为 Encoder 的主干网络。Xception 是一种高效的卷积神经网络架构,它比传统的 ResNet 有更少的参数量和更高的计算效率。

  2. ASPP:在 Encoder 的末尾,引入了 Atrous Spatial Pyramid Pooling (ASPP) 模块。ASPP 可以捕获不同尺度下的上下文信息,从而更好地理解图像的语义信息。

  3. Decoder:DeepLab v3+ 使用了一个特定的 decoder 模块,该模块通过上采样操作将 Encoder 提取的特征图恢复到原始输入图像的大小。这样做的目的是整合全局信息和局部信息,使得模型能够更好地进行语义分割。

  4. 深度监督:DeepLab v3+ 还引入了深度监督的技术。在不同层次的 decoder 中添加损失函数,使得低层次的特征也能参与监督。这样可以有助于更好地融合多尺度信息,提高模型的性能。

  5. 支持任意大小输入:DeepLab v3+ 通过平均池化策略来处理不同尺寸的输入图像,因此可以接受任意大小的图像进行语义分割任务,而不受固定输入尺寸的限制。

综上所述,DeepLab v3+ 的核心思想是通过 Encoder-Decoder 结构和其他改进措施,更好地捕获图像的上下文信息和多尺度信息,从而在语义图像分割任务上取得更好的性能。

Abstract

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版)

2. DeepLab v3+ 实现思路

2.1 Backbone(主干网络)

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 的核心思想是通过一系列的创新设计来提高模型的性能和计算效率。主要的改进包括:

  1. 线性 Bottleneck 结构:MobileNet v2 引入了线性 Bottleneck 结构,用于构建深层网络。这个结构包括一个 1x1 卷积用于降维,然后是一系列的 Depthwise Separable Convolution(深度可分离卷积)层,最后再使用 1 × 1 1\times 1 1×1 卷积进行升维。这个结构在增加了网络深度的同时,减少了参数量和计算量。

  2. 逆残差结构:MobileNet v2 引入了逆残差结构,用于解决在深层网络中的梯度消失问题。这个结构在某些层中允许跳过连接,使得梯度能够更好地在网络中传播,有助于训练更深的网络。

  3. 线性激活函数:MobileNet v2 使用线性激活函数代替了传统的非线性激活函数(如 ReLU),这样可以避免在梯度传播过程中的信息损失,有助于训练更深的网络。

  4. 更宽的网络:MobileNet v2 使用了更宽的网络(即更多的通道数),以增加模型的表达能力和准确性。

  5. SE(Squeeze-and-Excitation)模块:MobileNet v2 引入了 SE 模块,用于增强网络对重要特征的关注程度。SE 模块通过自适应地调整通道之间的重要性,从而提高模型的性能。

MobileNet v2 在 ImageNet 图像分类任务上取得了优秀的性能,同时具有更高的计算效率,适合于部署在资源受限的移动设备和嵌入式系统上。由于其出色的性能和高效的计算特性,MobileNet v2 成为了移动端计算机视觉任务中的重要选择。

MobileNet v2 详细介绍请见博客:MobileNet系列 (v1 ~ v3) 理论讲解

2.1.1 逆残差结构(Inverted Residual Module)

我们看一下 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)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

逆残差结构可以分为两个部分:

  1. 主路径 / 特征提取部分(左):首先利用 1 × 1 1\times 1 1×1 卷积进行升维,然后利用 3 × 3 3\times 3 3×3深度可分离卷积进行特征提取,然后再利用 1 × 1 1\times 1 1×1卷积降维
  2. 残差部分 / 梯度回传部分(右):输入和输出直接相接

在这里插入图片描述

逆残差结构示意图

需要注意的是,在 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 倍下采样)的结果。

2.1.2 在 DeepLab v3+ 中实现 MobileNet v2 Backbone 代码

我们可以很容易获取到 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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71

这里我们需要对 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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

在上面的例子中,partial 函数将 add 函数的第一个参数固定为 5,然后返回一个新的函数 add_5。当我们调用 add_5(10) 时,它实际上等同于调用 add(5, 10),返回结果为 15。

使用 parital 函数在某些情况下可以使代码更加简洁和易读,并且使得函数的复用更加方便。

2.2 加强特征提取结构 —— ASPP + Concat

在这里插入图片描述

在 DeepLab v3+ 中,加强特征提取网络可以分为两部分:

  1. 在 Encoder 中,我们会对压缩 4 次的特征层利用不同膨胀系数的膨胀卷积进行特征提取;再进行沿通道堆叠、合并;之后再进行 1 × 1 1\times 1 1×1 卷积压缩特征。
  2. 在 Decoder 中,我们会对压缩 2 次的低级特征层利用 1 × 1 1\times 1 1×1 卷积调整通道数;再和经过上采样的第一部分得到的特征图进行 Channel 维度的 concat;最后再进行 2 次普通卷积。

2.2.1 ASPP 代码实现

文件名称: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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71

这里的 result 得到的就是图中绿色的特征图

2.2.2 Decoder 中的加强特征提取结构

文件名称: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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75

2.3 获取预测结构

利用 2.1 和 2.2 中得到的结果,我们可以获取输入图片的特征,此时,我们需要利用特征获得预测结果。

利用特征获得预测结果的过程可以分为 2 步:

  1. 利用一个 1 × 1 1\times 1 1×1 卷积进行通道调整,调整成 num_classes
  2. 利用 interpolate 函数进行上采样使得最终输出层的 [H, W] 和输入图片一样。

其代码实现已在 2.2.2 中给出。

2.4 损失函数(Loss Function)

DeepLab v3+ 所使用的损失函数由两部分组成:

  1. Cross Entropy Loss
  2. Dice Loss

2.4.1 Cross Entropy Loss(交叉熵损失)

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=1Nj=1Cyijlog(y^ij)

其中,

  • N N N 表示样本数量;
  • C C C 表示类别数量;
  • y i j y_{ij} yij 是样本 i i i 的真实标签,如果样本 i i i 属于类别 j j j 则为 1,否则为 0;
  • y ^ i j \hat{y}_{ij} y^ij 是模型预测的样本 i i i 属于类别 j j j 的概率。

交叉熵损失函数的计算过程是,对每个样本计算其预测概率与真实标签的交叉熵,并将所有样本的交叉熵求和,然后取负值。该损失函数的目标是 最小化模型预测与真实标签之间的差异,使模型能够更好地拟合训练数据,并提高在未见过的数据上的泛化能力

2.4.2 Dice Loss(Dice 系数损失函数)

Dice Loss 将语义分割的评价指标作为 Loss,Dice 系数是一种集合相似度度量函数,通常用于计算两个样本的相似度,取值范围在 [ 0 , 1 ] [0,1] [0,1]

当计算 Dice Loss 时,我们首先需要计算 Dice 系数,然后再将 Dice 系数取 1 减去得到 Dice Loss。

  1. 计算 Dice 系数

Dice = 2 ∣ X ∩ Y ∣ ∣ X ∣ + ∣ Y ∣ \text{Dice} = \frac{2|X \cap Y|}{|X| + |Y|} Dice=X+Y2∣XY

其中, X X X 表示预测结果的二值掩码(或分割区域), Y Y Y 表示真实结果的二值掩码。

  1. 计算 Dice Loss

Dice Loss = 1 − Dice \text{Dice Loss} = 1 - \text{Dice} Dice Loss=1Dice

Dice Loss 的取值范围在 [ 0 , 1 ] [0, 1] [0,1] 之间,越接近 0 表示预测结果和真实结果的相似度越高,损失越小,优化目标是将 Dice Loss 最小化,使得模型能够更好地拟合训练数据,提高在未见过的数据上的泛化能力。

3. 预测过程

3.1 预测概况

训练结果预测需要用到两个文件:

  1. deeplab.py
  2. predict.py

我们首先需要去 deeplab.py 里面修改 model_path 以及 num_classes,这两个参数必须要修改:

  • model_path:指向训练好的权值文件,在 logs\文件夹里
  • num_classes:指向检测类别的个数(需要+1(背景))

完成修改后就可以运行 predict.py 进行检测了。运行后输入图片路径即可检测。

CUDA_VISIBLE_DEVICE=2,3 python predict

# 模型加载完毕后需要输入要预测图片的路径
  • 1
  • 2
  • 3

3.2 前处理(预处理)

DeepLab v3+ 在预测时主要包括两部分:

  1. 预测的前/预处理(Preprocessing)
  2. 预测的后处理(Postprocessing)

接下来我们先说明一下预处理部分。


文件名称: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)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

以上就是前处理的过程,其中重点是 不失真的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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

3.3 后处理

    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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79

4. 训练部分

4.1 训练文件

我们使用的训练文件均采用 PASCAL VOC 的格式。语义分割模型训练的文件分为两部分:

  1. 原图
  2. 标签

如下图所示。

在这里插入图片描述

原图就是普通的 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 模式打开真实标签的。当然,对于计算机而言,我们可以根据像素值的大小对不同类别进行归类。

4.2 数据集准备

本文使用 PASCAL VOC 格式进行模型训练,训练前需要自己制作好数据集,如果没有自己的数据集,可以下载官方数据集。

PASCAL VOC 2012 数据集下载地址:Visual Object Classes Challenge 2012 (VOC2012)

PASCAL VOC 解压好之后,目录结构如下:

`-- VOCdevkit
    `-- VOC2012
        |-- Annotations
        |-- ImageSets
        |   |-- Action
        |   |-- Layout
        |   |-- Main
        |   `-- Segmentation
        |-- JPEGImages
        |-- SegmentationClass
        `-- SegmentationObject
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

训练前将 图片文件 放在 VOCdevkit/ 文件夹下的 VOC2012/ 文件夹下的 JPEGImages 中。
训练前将 标签文件 放在 VOCdevkit/ 文件夹下的 VOC2012/ 文件夹下的 SegmentationClass 中。

4.3 标准自己的数据集

工具:labelme

pip install labelme
labelme  # 打开labelme
  • 1
  • 2
  1. Open Dir:打开我们要标注文件所在文件夹
  2. Create Polygons:开始标注多边形
  3. 标注好之后,可以继续标注下一张图片(使用按键 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  # 这是原始图像的宽度,以像素为单位。
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

在手工标注得到 .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')
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 原图的后缀是 .jpg
  • 标签的后缀是 .png

结果演示:

在这里插入图片描述

4.4 数据集处理(训练自己的数据)

在完成数据集的摆放之后,我们需要对数据集进行下一步的处理,目的是获得训练用的 train.txt 以及val.txt,需要用到根目录下的 voc_annotation.py

如果训练 PASCAL VOC 原始数据集,则不需要运行下面脚本(数据集自带 train.txtval.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文件。")
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94

4.5 开始网络训练

确保数据集文件夹中有 train.txtval.txt,此时我们可以开始训练了。

需要注意的是❗️:

  • num_classes 用于指向检测类别的个数 + 1(包含背景类)
  • 如:PASCAL VOC 数据集类别为 20,则 num_classes=21
  • 其他数据集同理

除此之外在 train.py 文件夹下面,选择:

  • backbone:使用 Xception 还是 MobileNet v2 作为 Backbone
  • model_path:预训练权重位置(要和主干模型相对应)
  • downsample_factor:下采样系数(可选值:8 或 16)
    • 下采样系数越大,则模型训练越快,理论上效果要差一些

之后就可以开始训练了。

4.6 训练结果预测

训练结果预测需要用到两个文件,分别是

  1. deeplab.py
  2. predict.py

与文中 3.1 无明显区别。

4.7 训练参数解析

训练分为两个阶段:

  1. 冻结阶段:冻结阶段的目的是在训练过程中固定模型的主干部分(Backbone 不再更新参数),也就是特征提取网络部分,而只训练顶部的分类器(通常是全连接层或者卷积层)部分。冻结阶段通常用于初期训练,特别是在机器性能有限,显存较小或显卡性能较差的情况下。在冻结阶段,可以设置 Freeze_Epoch 等于 UnFreeze_Epoch,此时仅仅进行冻结训练。

  2. 解冻阶段:解冻阶段是在冻结阶段之后进行的,此时模型的主干部分被解冻,即所有参数都可以进行更新。解冻阶段的目的是对整个模型进行微调,以适应特定任务的需求。在解冻阶段,可以设置适当的 UnFreeze_Epoch 来控制训练轮数(或迭代次数)。

总结来说,冻结阶段用于先训练分类器部分,解冻阶段用于对整个模型进行微调。Freeze_EpochUnFreeze_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 时失效)

4.8 训练参数建议

4.8.1 从整个模型的预训练权重开始训练

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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

其中:UnFreeze_Epoch 可以在 100-300 之间调整。

4.8.2 从主干网络的预训练权重开始训练

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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

其中:

  • 由于从主干网络的预训练权重开始训练,主干的权值不一定适合语义分割,需要更多的训练跳出局部最优解。
  • UnFreeze_Epoch 可以在 120-300 之间调整。
  • Adam 相较于 SGD 收敛的快一些。因此 UnFreeze_Epoch 理论上可以小一点,但依然推荐更多的 Epoch

4.8.3 batch_size 的设置

在显卡能够接受的范围内,越大越好。显存不足与数据集大小无关,提示显存不足请调小 batch_size。

注意❗️:

  • 受到 BatchNorm 层影响,batch_size 最小为 2,不能为 1

正常情况下 Freeze_batch_size 建议为 Unfreeze_batch_size 的 1 ~ 2 倍。不建议设置的差距过大,因为关系到学习率的自动调整。

5. 训练结果

5.1 训练概况

===>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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

5.2 mean IoU(平均交并比)

Mean IoU(mIoU) 是语义分割任务中最常用的指标之一。它是所有类别的交并比(Intersection over Union,IoU)的平均值。对于每个类别,IoU 表示预测的区域与真实区域的交集面积除以它们的并集面积。mIoU 衡量模型在所有类别上的分割准确性,取值范围为 [ 0 , 1 ] [0, 1] [0,1],越接近 1 表示模型性能越好。

在这里插入图片描述

5.3 mPA(平均像素级准确率)

mPA(mean Pixel Accuracy) 是像素级准确率的平均值。像素级准确率是衡量模型对每个像素的预测准确率的指标。它是正确分类的像素数量与总像素数量的比例。mPA 衡量模型在整个数据集上的像素分类准确性。

在这里插入图片描述

5.4 mPrecision(平均精确率)

  • Precision(精确率):是二分类问题中常用的指标之一,用于衡量模型在预测为正类别的样本中的准确率。在语义分割中,可以将每个类别视为一个二分类问题,将预测正确的像素数除以预测为正类别的像素数来计算该类别的精确率。
  • mPrecision(mean Precision):是所有类别精确率的平均值,用于衡量模型在所有类别上的预测准确率。

在这里插入图片描述

5.5 mRecall(平均召回率)

  • Recall(召回率):是二分类问题中常用的指标之一,用于衡量模型对正类别样本的识别能力。在语义分割中,可以将每个类别视为一个二分类问题,将预测正确的像素数除以真实正类别的像素数来计算该类别的召回率。
  • mRecall(mean Recall):是所有类别召回率的平均值,用于衡量模型在所有类别上对正类别样本的整体识别能力。

在这里插入图片描述

知识来源

  1. https://www.bilibili.com/video/BV173411q7xF
  2. https://blog.csdn.net/weixin_44791964/article/details/120113686
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Monodyee/article/detail/678170
推荐阅读
相关标签
  

闽ICP备14008679号