FCN实战——基于PASCAL VOC 2012数据集

声明:本博客为记录自己的学习过程,不做商业用途,引用部分会注明

本文基于:知乎@ 小白Horace的博文进行复现

下面两个blog写得也不错,对于理解很有帮助。

Pytorch实战语义分割(VOC2012)_voc2012语义分割-CSDN博客

pytorch语义分割中CrossEntropyLoss()损失函数的理解与分析_nn.crossentropyloss 语义分割-CSDN博客

前言

FCN网络,出自于_Fully Convolutional Networks for Semantic Segmentation,_为语义分割的开山之作,目前的被引用次数为47117次,可谓影响深远。它由Jonathan Long、Evan Shelhamer和Trevor Darrell于2015年提出,并在图像语义分割领域取得了重要的突破。

论文简述

论文摘要中提到,卷积神经网络(Convolutional Networks)在视觉领域非常强大。作者的最重要的贡献是,构建一种全卷积神经网络( Fully Convolutional Networks )。

An FCN naturally operates on an input of any size, and produces an output of corresponding (possibly resampled) spatial dimensions.

它能够自然地接受任意大小的输入,并通过有效的学习和推理产生相应大小的输出。

最重要的是:

The natural next step in the progression from coarse to fine inference is to make a prediction at every pixel.

对每一个像素进行预测,实现像素级分类 。换句话说,如果图像分类是用整张图的像素预测一个标签,那么语义分割就是整张图的像素预测整张图的像素类别,实现end-to-end

他将图像分类中的全连接层换成了卷积层,如论文中的下图所示:

我们知道,输出维度通常会通过子采样来减少,分类网络子采样以保持滤波器较小且计算要求合理。输入的大小减少一个因子,该因子等于输出单元receptive fields的一个像素步幅。

通过前置知识,我们知道输入的图片经过一系列的卷积层和池化层后,receptive fields 不断增大,同时图片的尺寸不断的减小,作者如何返回原图大小呢?

**上采样:**Another way to connect coarse outputs to dense pixels is interpolation.

将粗略输出连接到密集像素的另一种方法是插值。该论文选取的上采样方法是后向卷积(Deconvolution Networks),原因是实现起来很简单,只需要将卷积的向前和向后传递颠倒过来。这样的反卷积层不需要fixed就可以学习,甚至可以学习非线性上采样,最终效果好。

为了便于学习,先不纠结后向卷积的具体原理,知道它可以上采样(放大特征图)即可。放一个学习链接以便后续使用:

抽丝剥茧,带你理解转置卷积(反卷积)_抽丝剥茧带你理解转置卷积-CSDN博客

综上,作者采用全卷积和后向卷积上采样的方法构建FCN网络,在PASCAL VOC数据集达到sota

网络结构具体搭建

上面提到,FCN把分类网络中的全连接层换成了卷积层,而前面的部分可服用语义分割的网络层(卷积、池化),也称为**backbone。**通过backbone提取特征,配给不同的网络层,完成不同的任务。因此如何选取backbone也很重要,论文里使用VGG 16作backbone性能最好,mIU达到了56.0。

实战

首先,我们要获取数据集,本文采用PASCAL VOC 2012公共数据集,地址为The PASCAL Visual Object Classes Challenge 2012 (VOC2012) (ox.ac.uk)

下载好数据集后,对数据集进行分析,这里生成一个目录结构以便理解

bash 复制代码
VOCdevkit
└── VOC2012
     ├── Annotations               # 所有的图像标注信息(XML文件)
     ├── ImageSets    
     │   ├── Action                # 人的行为动作图像信息
     │   ├── Layout                # 人的各个部位图像信息
     │   │
     │   ├── Main                  # 目标检测分类图像信息
     │   │     ├── train.txt       # 训练集(5717)
     │   │     ├── val.txt         # 验证集(5823)
     │   │     └── trainval.txt    # 训练集+验证集(11540)
     │   │
     │   └── Segmentation          # 目标分割图像信息
     │         ├── train.txt       # 训练集(1464)
     │         ├── val.txt         # 验证集(1449)
     │         └── trainval.txt    # 训练集+验证集(2913)
     │ 
     ├── JPEGImages                # 所有图像文件
     ├── SegmentationClass         # 语义分割png图(基于类别)
     └── SegmentationObject        # 实例分割png图(基于目标)

不妨先读取几张图片看看效果?这里我使用的编辑器是VS code,环境配置如下:

makefile 复制代码
paddle: 2.6.0matplotlib: 3.8.2numpy: 1.26.2pillow: 10.0.1tqdm: 4.65.0requests: 2.31.0

这里我创建了一个ipynb文件,导入官方标准库,使用Pillow库读取图片,以某张图片为例,定义该图片的图像路径和标签图像路径,并展示该原图和对应的标签图片。

kotlin 复制代码
# 官方库导入import sys import paddleimport paddle.nn as nnimport paddle.vision.transforms as Timport paddle.nn.functional as Fimport matplotlib.pyplot as pltimport numpy as npimport randomfrom PIL import Imageimport osimport os.path as ospfrom tqdm import tqdm
#路径 rt = r"E:\VOCdevkit\VOC2012"image = osp.join(rt, r"JPEGImages\2010_005876.jpg")label = osp.join(rt, r"SegmentationClass\2010_005876.png")

# 展示一张图片
pil_image = Image.open(image)
pil_image  

#展示对应的标签图片
pil_label = Image.open(label)
pil_label

显然是对应的,接下来我们打印原图和标签图像的大小,将PIL图像对象转换为NumPy数组,然后看看数组的形状和数据信息。

scss 复制代码
#打印图像和标签图像的大小print(pil_image.size)print(pil_label.size)
image_array = np.array(pil_image)  
# PIL转ndarray格式为HWC
label_array = np.array(pil_label)
print(image_array.shape)
print(label_array.shape)
print(image_array.dtype)
print(label_array.dtype)

(500, 375)
(500, 375)
(375, 500, 3)
(375, 500)
uint8
uint8

由此得知原图与标签图片大小一致,均为500*375,且原图有三通道RGB,标签图片是灰度级图片。下面来看看图片中出现过的像素值,了解图像中包含的不同颜色或者不同灰度。先来看原图:

ini 复制代码
#返回所有出现过的像素值,了解图像中包含的不同颜色或灰度级别np.unique(image_array)

array([  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,  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,  95,  96,  97,  98,  99, 100, 101, 102, 103,
       104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116,
       117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129,
       130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142,
       143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155,
       156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168,
       169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181,
       182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194,
       195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207,
       208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220,
       221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233,
       234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246,
       247, 248, 249, 250, 251, 252, 253, 254, 255], dtype=uint8)

再来看看标签图片:

ini 复制代码
np.unique(label_array)array([  0,  14,  15,  16, 255], dtype=uint8)

值为 0 的像素通常表示背景类别,其他值则可能表示不同的目标类别或者物体的边界等。

为了更好地理解图片,上面标签图片地颜色映射显然不够好,因此,这里选用matplotlib中地库进行更漂亮地可视化。

dart 复制代码
#可视化,颜色映射import matplotlib.pyplot as pltplt.imshow(label_array)plt.show()

将图片转为RGB模式,并用新的颜色映射,写出以下代码:

scss 复制代码
# 各种标签所对应的颜色colormap = [[0,0,0],[128,0,0],[0,128,0], [128,128,0], [0,0,128],            [128,0,128],[0,128,128],[128,128,128],[64,0,0],[192,0,0],            [64,128,0],[192,128,0],[64,0,128],[192,0,128],            [64,128,128],[192,128,128],[0,64,0],[128,64,0],            [0,192,0],[128,192,0],[0,64,128]]#颜色值是三个通道分量组合而成的,确保可以映射到所有可能的RGB颜色值。cm2lbl = np.zeros(256**3)# 枚举的时候i是下标(循环变量),cm是一个三元组(颜色值),分别标记了RGB值for i, cm in enumerate(colormap):    cm2lbl[(cm[0]*256 + cm[1])*256 + cm[2]] = i# 将标签按照RGB值填入对应类别的下标信息def image2label(im):    data = np.array(im, dtype="int32")    idx = (data[:,:,0]*256 + data[:,:,1])*256 + data[:,:,2]    return np.array(cm2lbl[idx], dtype="int64")

#转换为RGB模式,查看转换后的类别标签图像中有哪些不同的类别
pil_label = Image.open(label).convert("RGB")
trans_img = image2label(pil_label)
print(np.unique(trans_img))
plt.imshow(trans_img)plt.show()

刚刚我们只是拿一个图片进行尝试,紧接着,我们就要进行数据集地搭建:

scss 复制代码
expanded_label = np.expand_dims(label_array, axis=2)  # 扩展一个C维,效果等同于label_array[:, :, np.newaxis]print(expanded_label.shape)concated_data = np.concatenate((image_array, expanded_label), axis=2)print(concated_data.shape)
(375, 500, 1)
(375, 500, 4)

在给定的情况下,从 (375, 500, 3) 变为 (375, 500, 4) 并不会对图像本身产生实质性的影响。

因为这个额外的通道是由标签图像生成的,它并不代表原始图像中的任何信息,而是用于将类别标签与原始图像合并。这意味着对于原始图像而言,图像数据本身并没有改变,仅是在其后面添加了一个额外的通道,用于存储类别标签

在训练模型之前,会使用DataLoader 读取一批次数据,而要求是输入数据形状相同。PASCAL VOC 2012数据集地图片尺寸并不相同,因此我们采用裁剪图片的方法(crop)。

值得注意的是语义分割标签与原始图像是对应的,所以要同时变换,保证一致性,而上面代码中扩展的C维正是起到了这个作用,我们接下来进行随机裁剪,看看效果:

less 复制代码
#RandomCrop 对输入的图像进行随机裁剪操作import paddle.vision.transforms as Ttrans = T.RandomCrop(size=(224, 224))crop_array = trans(concated_data)
#将 NumPy 数组转换为图像对象,并显示了裁剪后的图像Image.fromarray(crop_array[:, :, :3].astype("uint8"))#PIL 库要求图像数据的类型必须是 uint8 类型
#将 标签通道 显示出来plt.imshow(crop_array[:, :, 3].astype("uint8"))plt.show()

由上述图片我们得知,原图与标签图的确得到了同步裁剪的效果。

接下来,我们开始正式搭建数据集:

scss 复制代码
#打开了train.txt,并读取了文件中的内容f = open(osp.join(rt, "ImageSets", "Segmentation", "train.txt"))contents = f.readlines()
#构建了图像和标签目录的路径image_dir = osp.join(rt, "JPEGImages")mask_dir = osp.join(rt, "SegmentationClass")print(image_dir)print(mask_dir)
#从给定的图像路径列表中筛选出尺寸大于指定裁剪尺寸的图像def filter_size(images_path, crop_size=(320, 480)):    imgs = []    crop_size = crop_size[::-1]  # PIL图像尺寸为W,H,方便比较    for image_path in images_path:        pil_image = Image.open(image_path)        if pil_image.size[0] > crop_size[0] and pil_image.size[1] > crop_size[1]:            imgs.append(image_path)        else:            continue    return imgs
#从给定的图像路径列表中筛选出尺寸大于指定裁剪尺寸的图像
test_imgs = filter_size(images)test_pil_img = Image.open(test_imgs[10])print(test_pil_img.size)test_pil_img

这里使用了 filter_size( ) 函数来筛选尺寸大于指定裁剪尺寸的图像,并获取了第一个符合条件的图像路径。然后使用 PIL 库的 Image.open( )函数打开了这个图像文件,并将其加载为 PIL 图像对象 test_pil_img。最后,打印了这个图像的尺寸和图像对象本身。

接下来加载VOC数据集的图像,并搭建网络:

scss 复制代码
#可以用于加载 VOC 数据集的图像和标签数据,并进行必要的预处理和变换import paddleimport paddle.vision.transforms as Timport osimport os.path as ospimport randomsubset_ratio = 0.2class VOCSegData(paddle.io.Dataset):    def __init__(self, voc_root: str, train: bool = True, crop_size: tuple = (320, 480), subset_ratio: float = 1.0):        super(VOCSegData, self).__init__()        self.crop_size = crop_size  # 传入的为[H, W]        txt_name = None        if train is True:            txt_name = "train.txt"        else:            txt_name = "val.txt"        image_dir = osp.join(voc_root, 'JPEGImages')        mask_dir = osp.join(voc_root, 'SegmentationClass')        txt_path = osp.join(voc_root, "ImageSets", "Segmentation", txt_name)                f = open(txt_path)        contents = f.readlines()        random.shuffle(contents)  # 随机打乱样本顺序        subset_size = int(len(contents) * subset_ratio)  # 计算子集大小        contents = contents[:subset_size]  # 截取子集        images = list(map(lambda x: osp.join(image_dir, x.strip() + ".jpg"), contents))        masks = list(map(lambda x: osp.join(mask_dir, x.strip() + ".png"), contents))        self.images = self.filter_size(images)        self.masks = self.filter_size(masks)        print("Read "+ str(len(self.images)), " images. Filter {}".format(len(images)-len(self.images)))        assert (len(self.images) == len(self.masks))                #定义图像和标签共同的变换操作序列        self.trans_both = T.Compose([            T.RandomCrop(self.crop_size),            # T.RandomHorizontalFlip(prob=0.5),            # T.RandomVerticalFlip(prob=0.5)  为了预测时方便,可以将这两行注释掉        ])  # 对image和mask共同的变换操作        #定义图像的变换操作序列,包括将图像转换为张量和归一化        self.trans_img = T.Compose([            T.ToTensor(),            T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])        ])    #根据指定的裁剪尺寸过滤图像,将尺寸符合要求的图像路径保存到列表中并返回    def filter_size(self, images_path):        imgs = []        crop_size = self.crop_size[::-1]  # PIL图像尺寸为W,H,方便比较        for image_path in images_path:            pil_image = Image.open(image_path)            if pil_image.size[0] > crop_size[0] and pil_image.size[1] > crop_size[1]:                imgs.append(image_path)            else:                continue        return imgs            #用于支持索引访问,使得对象可以像序列一样被索引。    def __getitem__(self, idx):        # 获取变换操作        trans_both = self.trans_both        trans_img = self.trans_img        # 读取数据        image = np.array(Image.open(self.images[idx]))        mask = image2label(Image.open(self.masks[idx]).convert("RGB"))        mask = np.expand_dims(mask, axis=2)        concated_data = np.concatenate((image, mask), axis=2)        trans_array = trans_both(concated_data)        # 取出变换后的数据        image = trans_array[:, :, :3]        mask = trans_array[:, :, 3]                image = trans_img(image.astype("uint8"))  # 输出: shape:[C, H, W], dtype:float32        mask = paddle.to_tensor(mask, dtype="int64")        return image, mask        #这是一个特殊方法,用于支持 len() 函数,返回对象的长度或元素个数。    def __len__(self):        return len(self.images)

file_path = "E:\VOCdevkit\\VOC2012\\ImageSets\\Segmentation\\train.txt"if os.path.exists(file_path):    # 文件存在,继续处理    passelse:    # 文件不存在,输出提示或进行相应的处理    print("文件不存在:", file_path)
#创建了一个 VOCSegData 类的实例voc_root = r"E:\VOCdevkit\VOC2012"crop_size = (320, 480)train_data = VOCSegData(voc_root=voc_root, train=True, crop_size=crop_size)
#获取了第一个样本的图像和标签数据img, mask = train_data[0]

train_loader = paddle.io.DataLoader(train_data, batch_size=16, shuffle=True)for data in train_loader:    imgs, masks = data    print(imgs.shape)    print(masks.shape)    break#创建了训练数据集和验证数据集的 DataLoader 对象,并加载voc_root = r"E:\VOCdevkit\VOC2012"crop_size = (320, 480)train_data = VOCSegData(voc_root=voc_root, train=True, crop_size=crop_size)val_data = VOCSegData(voc_root=voc_root, train=False, crop_size=crop_size)train_loader = paddle.io.DataLoader(train_data, batch_size=32, shuffle=True)val_loader = paddle.io.DataLoader(val_data, batch_size=32)

#双线性插值的卷积核,用于在模型中进行上采样操作def bilinear_kernel(in_channels, out_channels, kernel_size):    factor = (kernel_size + 1) // 2    if kernel_size % 2 == 1:        center = factor - 1    else:        center = factor - 0.5    og = np.ogrid[:kernel_size, :kernel_size]    filt = (1 - abs(og[0] - center) / factor) * \           (1 - abs(og[1] - center) / factor)    weight = np.zeros((in_channels, out_channels, kernel_size, kernel_size),                      dtype='float32')    weight[range(in_channels), range(out_channels), :, :] = filt    return paddle.to_tensor(weight, dtype="float32")'''这段代码定义了一个 FCN8s 模型,用于语义分割任务。'''class FCN8s(nn.Layer):    def __init__(self, num_classes=21):        super(FCN8s, self).__init__()        # num_classes要包含背景,如果是PASCAL VOC则是20+1        self.layer1 = self.make_block(num=2, in_channels=3, out_channels=64)        self.layer2 = self.make_block(num=2, in_channels=64, out_channels=128)        self.layer3 = self.make_block(num=3, in_channels=128, out_channels=256)        self.layer4 = self.make_block(num=3, in_channels=256, out_channels=512)        self.layer5 = self.make_block(num=3, in_channels=512, out_channels=512)        # 下面的两个卷积层代替了原来VGG网络的全连接层(原本为4096,此处可根据gpu性能,设置为其他数,此处设为2048)        mid_channels = 2048        self.conv6 = nn.Conv2D(in_channels=512, out_channels=mid_channels, kernel_size=7, padding=3)        self.conv7 = nn.Conv2D(in_channels=mid_channels, out_channels=mid_channels, kernel_size=1)                # 3个1*1的卷积,用于改变pool的通道数,为了后续融合语义信息        self.score32 = nn.Conv2D(in_channels=mid_channels, out_channels=num_classes, kernel_size=1)        self.score16 = nn.Conv2D(in_channels=512, out_channels=num_classes, kernel_size=1)        self.score8 = nn.Conv2D(in_channels=256, out_channels=num_classes, kernel_size=1)                # 3个转置卷积,用于扩大特征图        # 若参数kernel_size:stride:padding=4:2:1,此时stride为扩大倍数        # 需要为转置卷积初始化weight权重参数,否则很难收敛,且准确率低        weight_8x = paddle.ParamAttr(            initializer=paddle.nn.initializer.Assign(bilinear_kernel(num_classes, num_classes, 16))        )        self.up_sample8x = nn.Conv2DTranspose(            in_channels=num_classes,            out_channels=num_classes,            kernel_size=16, stride=8, padding=4,            weight_attr=weight_8x        )                weight_16x = paddle.ParamAttr(            initializer=paddle.nn.initializer.Assign(bilinear_kernel(num_classes, num_classes, 4))        )        self.up_sample16x = nn.Conv2DTranspose(            in_channels=num_classes,            out_channels=num_classes,            kernel_size=4, stride=2, padding=1,            weight_attr=weight_16x        )                weight_32x = paddle.ParamAttr(            initializer=paddle.nn.initializer.Assign(bilinear_kernel(num_classes, num_classes, 4))        )        self.up_sample32x = nn.Conv2DTranspose(            in_channels=num_classes,            out_channels=num_classes,            kernel_size=4, stride=2, padding=1,            weight_attr=weight_32x        )                            def make_block(self, num: int, in_channels: int, out_channels: int, padding=1):        """根据传入的in,out和需要构建的块数搭建网络块"""        blocks = []        blocks.append(nn.Conv2D(in_channels=in_channels, out_channels=out_channels, kernel_size=3, padding=padding))        blocks.append(nn.ReLU())        for i in range(num-1):            blocks.append(nn.Conv2D(in_channels=out_channels, out_channels=out_channels, kernel_size=3, padding=1))            blocks.append(nn.ReLU())        blocks.append(nn.MaxPool2D(kernel_size=2, stride=2, ceil_mode=True))                return nn.Sequential(*blocks)        def forward(self, inputs):        # inputs [3, 1, 1],以原始输入图像尺寸为1        # features        out = self.layer1(inputs)  # [64, 1/2, 1/2],论文这里实际上有padding100,是为了接受各种图片大小        out = self.layer2(out)  # [128, 1/4, 1/4]        pool3 = self.layer3(out)  # [256, 1/8, 1/8]        pool4 = self.layer4(pool3)  # [512, 1/16, 1/16]        pool5 = self.layer5(pool4)  # [512, 1/32, 1/32]        x = self.conv6(pool5)  # [mid_channels, 1/32, 1/32]        x = self.conv7(x)  # [mid_channels, 1/32, 1/32]        score32 = self.score32(x)  # [num_classes, 1/32, 1/32]                up_pool16 = self.up_sample32x(score32)  # [num_classes, 1/16, 1/16]        score16 = self.score16(pool4)  # [num_classes, 1/16, 1/16]        fuse_16 = paddle.add(up_pool16, score16)                up_pool8 = self.up_sample16x(fuse_16)  # [num_classes, 1/8, 1/8]        score8 = self.score8(pool3)  # [num_classes, 1/8, 1/8]        fuse_8 = paddle.add(up_pool8, score8)        heatmap = self.up_sample8x(fuse_8)                return heatmap

#分析和打印模型的结构以及每一层的输出形状。model = FCN8s(num_classes=21)paddle.summary(model, (5, 3, 320, 480)) 

因为在调式代码的时候文件路径总报错,这里我做了一个文件检查。我们可以使用paddle.summary接口查看输入数据在网络中的变化情况及相关参数量。结果如下:

lua 复制代码
------------------------------------------------------------------------------
   Layer (type)        Input Shape          Output Shape         Param #    
==============================================================================
    Conv2D-73       [[5, 3, 320, 480]]   [5, 64, 320, 480]        1,792     
     ReLU-53       [[5, 64, 320, 480]]   [5, 64, 320, 480]          0       
    Conv2D-74      [[5, 64, 320, 480]]   [5, 64, 320, 480]       36,928     
     ReLU-54       [[5, 64, 320, 480]]   [5, 64, 320, 480]          0       
   MaxPool2D-21    [[5, 64, 320, 480]]   [5, 64, 160, 240]          0       
    Conv2D-75      [[5, 64, 160, 240]]   [5, 128, 160, 240]      73,856     
     ReLU-55       [[5, 128, 160, 240]]  [5, 128, 160, 240]         0       
    Conv2D-76      [[5, 128, 160, 240]]  [5, 128, 160, 240]      147,584    
     ReLU-56       [[5, 128, 160, 240]]  [5, 128, 160, 240]         0       
   MaxPool2D-22    [[5, 128, 160, 240]]  [5, 128, 80, 120]          0       
    Conv2D-77      [[5, 128, 80, 120]]   [5, 256, 80, 120]       295,168    
     ReLU-57       [[5, 256, 80, 120]]   [5, 256, 80, 120]          0       
    Conv2D-78      [[5, 256, 80, 120]]   [5, 256, 80, 120]       590,080    
     ReLU-58       [[5, 256, 80, 120]]   [5, 256, 80, 120]          0       
    Conv2D-79      [[5, 256, 80, 120]]   [5, 256, 80, 120]       590,080    
     ReLU-59       [[5, 256, 80, 120]]   [5, 256, 80, 120]          0       
   MaxPool2D-23    [[5, 256, 80, 120]]    [5, 256, 40, 60]          0       
    Conv2D-80       [[5, 256, 40, 60]]    [5, 512, 40, 60]      1,180,160   
     ReLU-60        [[5, 512, 40, 60]]    [5, 512, 40, 60]          0       
    Conv2D-81       [[5, 512, 40, 60]]    [5, 512, 40, 60]      2,359,808   
     ReLU-61        [[5, 512, 40, 60]]    [5, 512, 40, 60]          0       
    Conv2D-82       [[5, 512, 40, 60]]    [5, 512, 40, 60]      2,359,808   ...Params size (MB): 268.86
Estimated Total Size (MB): 3771.83

本文选择的损失函数是NLLLoss,这里的参数learning_rate,weight_decay是我们需要调整的,我尝试过几组,在后文中会提到。

ini 复制代码
# 定义损失函数,优化器
loss_fn = nn.NLLLoss()  
opt = paddle.optimizer.SGD(parameters=model.parameters(), learning_rate=1e-3, weight_decay=1e-5)

性能指标选取最为常见实用的MIoU指标,通过混淆矩阵计算:

scss 复制代码
#这段代码主要用于计算语义分割任务中的评估指标# 计算混淆矩阵def _fast_hist(label_true, label_pred, n_class):    # mask在和label_true相对应的索引的位置上填入true或者false    # label_true[mask]会把mask中索引为true的元素输出    mask = (label_true >= 0) & (label_true < n_class)    # np.bincount()会给出索引对应的元素个数    """    hist是一个混淆矩阵    hist是一个二维数组,可以写成hist[label_true][label_pred]的形式    最后得到的这个数组的意义就是行下标表示的类别预测成列下标类别的数量    比如hist[0][1]就表示类别为1的像素点被预测成类别为0的数量    对角线上就是预测正确的像素点个数    n_class * label_true[mask].astype(int) + label_pred[mask]计算得到的是二维数组元素    变成一位数组元素的时候的地址取值(每个元素大小为1),返回的是一个numpy的list,然后    np.bincount就可以计算各中取值的个数    """    hist = np.bincount(        n_class * label_true[mask].astype(int) +        label_pred[mask], minlength=n_class ** 2).reshape(n_class, n_class)    return hist"""label_trues 正确的标签值label_preds 模型输出的标签值n_class 数据集中的分类数"""def label_accuracy_score(label_trues, label_preds, n_class):    """Returns accuracy score evaluation result.      - overall accuracy      - mean accuracy      - mean IU      - fwavacc    """    hist = np.zeros((n_class, n_class))    # 一个batch里面可能有多个数据    # 通过迭代器将一个个数据进行计算    for lt, lp in zip(label_trues, label_preds):        # numpy.ndarray.flatten将numpy对象拉成1维        hist += _fast_hist(lt.flatten(), lp.flatten(), n_class)        # np.diag(a)假如a是一个二维矩阵,那么会输出矩阵的对角线元素    # np.sum()可以计算出所有元素的和。如果axis=1,则表示按行相加    """    acc是全局准确率 = 预测正确的像素点个数/总的像素点个数    acc_cls是预测的每一类别的准确率(比如第0行是预测的类别为0的准确率),然后求平均    iu是交并比,mean_iu就是对iu求了一个平均    """    acc = np.diag(hist).sum() / hist.sum()    acc_cls = np.diag(hist) / hist.sum(axis=1)    # nanmean会自动忽略nan的元素求平均    acc_cls = np.nanmean(acc_cls)    iu = np.diag(hist) / (hist.sum(axis=1) + hist.sum(axis=0) - np.diag(hist))    mean_iu = np.nanmean(iu)    return acc, acc_cls, mean_iu

然后定义训练验证函数:

scss 复制代码
num_classes = 21def train_val(net, opt, loss_fn, train_loader, val_loader, epochs=30, save_path="./params", log_path="log.txt", param_name="miou{}.pdparams"):    if not osp.exists(save_path):        os.mkdir(save_path)        best_miou = 0.0        for epoch in range(epochs):        train_bar = tqdm(train_loader)        f = open(log_path, mode='a')                # 训练阶段        net.train()        for batch_id, data in enumerate(train_bar):            imgs, true_masks = data            out = net(imgs)            out = F.log_softmax(out, axis=1)            loss = loss_fn(out, true_masks)            loss.backward()  # 反向传播            opt.step()            opt.clear_grad()  # 梯度清零            train_log_info = "Epoch [{}/{}], batch_id {}, loss {}".format(                epoch, epochs, batch_id, loss.item()            )            train_bar.desc = train_log_info            f.write(train_log_info+'\n')                    f.close()  # 将内容写入                _eval_loss = 0.0        _eval_acc = 0.0        _eval_acc_cls = 0.0        _eval_mean_iu = 0.0        f = open(log_path, mode='a')        # 验证阶段        net.eval()        val_bar = tqdm(val_loader)        for data in val_bar:            with paddle.no_grad():                imgs, true_masks = data                out = net(imgs)                out = F.log_softmax(out, axis=1)                loss = loss_fn(out, true_masks)                _eval_loss += loss.item()                                label_pred = out.argmax(axis=1).numpy()  # [B, H, W]                label_true = paddle.squeeze(true_masks).numpy() # [B, H, W]                for lbt, lbp in zip(label_pred, label_true):                    acc, acc_cls, mean_iu = label_accuracy_score(lbt, lbp, num_classes)                    _eval_acc += acc                    _eval_acc_cls += acc_cls                    _eval_mean_iu += mean_iu                miou = _eval_mean_iu / len(val_data)        if miou > best_miou:            best_miou = miou            paddle.save(net.state_dict(), osp.join(save_path, param_name.format(int(best_miou*100))))                eval_log_info = "Epoch [{}/{}], Valid loss {:.4f}, Valid Acc {:.4f}, Valid mIoU {:.4f}".format(            epoch, epochs, _eval_loss / len(val_data), _eval_acc / len(val_data), miou        )        print(eval_log_info)        f.write(eval_log_info+'\n')        f.close()  # 将内容写入    print("Finish!")

# 定义损失函数,优化器loss_fn = nn.NLLLoss()  opt = paddle.optimizer.SGD(parameters=model.parameters(), learning_rate=1e-3, weight_decay=1e-5)

不使用预训练的话训练起来比较难,因此本文选择预训练后的模型对FCN进行训练。

使用迁移学习时,将预训练好的VGG16模型应用于新的任务或数据集上,以加速新任务的学习过程。在使用VGG16进行迁移学习时,通常会将VGG16的权重加载到一个新的模型中,然后针对新任务的特定数据集进行微调。通过这种方式,可以利用VGG16在大规模图像数据集上预先训练的特征提取能力,并在较小的数据集上进行微调,以适应新任务的要求。这种方法通常可以在较少的数据和计算资源下取得很好的性能。

ini 复制代码
model = FCN8s(num_classes=21)
from paddle.vision.models import vgg16
pre_model = vgg16(pretrained=True)  # 导入vgg16预训练模型
# 若不熟悉vgg16网络结构,可用pre_model.sublayers()方法查看网络结构
pre_weights = pre_model.state_dict()  # 拿到其权重参数
print(type(pre_weights))
print(pre_weights.keys())

上述权重中,有一部分权重我们是不需要的,包含classifier的我们都不需要,因为那是VGG16中的全连接层。而我们FCN后续将那三个全连接层换成了卷积层。

ini 复制代码
key_names = list(pre_weights.keys())
for key in key_names:
    if 'classifier' in key:
        del pre_weights[key]  # 将全连接层的权重删除

#将预训练参数的键名改为我们网络模块的键名
pre_keys = list(pre_weights.keys())
print(pre_keys)
print("VGG16网络特征提取层的参数数量:", len(pre_keys))
my_weights = model.state_dict()
new_keys = list(my_weights.keys())[:26]
print(new_keys)
for pre, new in zip(pre_keys, new_keys):
    pre_weights[new] = pre_weights[pre]  # 添加以自己网络权重名字的参数
    del pre_weights[pre]
print(pre_weights.keys())  # 看看效果,修改成功

# 定义损失函数,优化器
loss_fn = nn.NLLLoss()  
opt = paddle.optimizer.SGD(parameters=model.parameters(), learning_rate=0.003, weight_decay=1e-6)

#开始训练
train_val(
    net=model,
    opt=opt,
    loss_fn=loss_fn,
    train_loader=train_loader,
    val_loader=val_loader,
    epochs=50,
    log_path="transfered_vgg16_log2.txt",
    param_name="FCN8s_VGG16_transfered2_miou{}.pdparams"
)

这里我们使用的是SGD优化器,也可以尝试用Adam优化器。以下是可视化展示:

可见效果不是很好,最大miou仅有31.78,但是比不进行预训练的结果已经好很多了,接下来尝试一下ResNet34作为backbone进行预训练,进而对FCN的模型进行训练。

由于训练过程中的miou值波动不大,这里我的epoch选为8次,节约一点时间,可视化如下:

可见效果明显比VGG16预训练后的好很多,当然,语义分割还有很多可用的模型,本文用的是 FCN,其它模型可能会有更好的表现:

Deeplab V3+ 具有可分离卷积的编码器/解码器,用于语义图像分割

GCN 通过全局卷积网络改进语义分割

UperNet 统一感知解析

ENet 用于实时语义分割的深度神经网络体系结构

U-Net 用于生物医学图像分割的卷积网络

SegNet 用于图像分段的深度卷积编码器-解码器架构。 还有(DUC,HDC)、PSPNet等。

知乎@ 小白Horace说的很好:

搞图像分割时,我们并不能一味关心性能指标,而不看最后预测的图像质量

所以接下来我们看一下预测的图片质量

虽然能大概分辨出来,但是仍然比较粗糙,有很大的进步空间。

相关推荐
不能只会打代码28 分钟前
蓝桥杯例题一
算法·蓝桥杯
OKkankan34 分钟前
实现二叉树_堆
c语言·数据结构·c++·算法
ExRoc2 小时前
蓝桥杯真题 - 填充 - 题解
c++·算法·蓝桥杯
利刃大大2 小时前
【二叉树的深搜】二叉树剪枝
c++·算法·dfs·剪枝
天乐敲代码5 小时前
JAVASE入门九脚-集合框架ArrayList,LinkedList,HashSet,TreeSet,迭代
java·开发语言·算法
十年一梦实验室5 小时前
【Eigen教程】矩阵、数组和向量类(二)
线性代数·算法·矩阵
Kent_J_Truman5 小时前
【子矩阵——优先队列】
算法
快手技术6 小时前
KwaiCoder-23BA4-v1:以 1/30 的成本训练全尺寸 SOTA 代码续写大模型
算法·机器学习·开源
一只码代码的章鱼7 小时前
粒子群算法 笔记 数学建模
笔记·算法·数学建模·逻辑回归
小小小小关同学7 小时前
【JVM】垃圾收集器详解
java·jvm·算法