动手深度学习13:计算机视觉——语义分割、风格迁移

2023-11-16

文章目录

一、语义分割

参考李沐动手深度学习《13.9. 语义分割和数据集》哔哩哔哩视频

1.1 语义分割简介

  • 之前的目标检测都是用锚框来标注和预测图片中主体的位置,而锚框有时候框的是大概的位置。
  • 语义分割(semantic segmentation)可以识别并理解图像中每一个像素的内容,其语义区域的标注和预测是像素级的。所以,与目标检测相比,语义分割标注的像素级的边框显然更加精细。
    在这里插入图片描述
    语义分割应用:
    在这里插入图片描述
    无人驾驶:路面分割
    在这里插入图片描述
    在这里插入图片描述

   计算机视觉领域还有2个与语义分割相似的重要问题,即图像分割(image segmentation)和实例分割(instance segmentation)。

  • 图像分割将图像划分为若干组成区域,这类问题的方法通常利用图像中像素之间的相关性。以 上图为例,图像分割可能会将狗分为两个区域:一个覆盖以黑色为主的嘴和眼睛,另一个覆盖以黄色为主的其余部分身体。
  • 实例分割也叫同时检测并分割(simultaneous detection and segmentation)。与语义分割不同,实例分割不仅需要区分语义,还要区分不同的目标实例。例如,如果图像中有两条狗,则实例分割需要区分像素属于的两条狗中的哪一条。
    语义分割VS实例分割
    在这里插入图片描述
    语义分割:每个像素都分到各个类别(猫、狗)
    实例分割:目标检测进化版,每个像素分到各个主体(狗1、狗2、猫)

1.2 Pascal VOC2012 语义分割数据集

1.2.1下载、读取数据集

   最重要的语义分割数据集之一是Pascal VOC2012,下面我们深入理解一下这个数据集。
   数据集的tar文件大约为2GB,所以下载可能需要一段时间

%matplotlib inline
import os
import torch
import torchvision
from d2l import torch as d2l

d2l.DATA_HUB['voc2012'] = (d2l.DATA_URL + 'VOCtrainval_11-May-2012.tar',
'4e443f8a2eca6b1dac8a6c57641b67dd40621a49')
voc_dir = d2l.download_extract('voc2012', 'VOCdevkit/VOC2012')

  进入路径…/data/VOCdevkit/VOC2012之后,我们可以看到数据集的不同组件。

  • ImageSets/Segmentation路径:包含用于训练和测试样本的文本文件
  • JPEGImages和SegmentationClass路径:分别存储着每个示例的输入图像和标签。
  • 标签也采用图像格式,其尺寸和它所标注的输入图像的尺寸相同。 标签中颜色相同的像素属于同一个语义类别。
  • 定义read_voc_images函数:为将所有输入的图像和标签读入内存。
#@save
def read_voc_images(voc_dir, is_train=True):
    """读取所有VOC图像并标注"""
    txt_fname = os.path.join(voc_dir, 'ImageSets', 'Segmentation',
                             'train.txt' if is_train else 'val.txt')
    mode = torchvision.io.image.ImageReadMode.RGB
    with open(txt_fname, 'r') as f:
        images = f.read().split()
    features, labels = [], []
    for i, fname in enumerate(images):
        features.append(torchvision.io.read_image(os.path.join(
            voc_dir, 'JPEGImages', f'{fname}.jpg')))
        labels.append(torchvision.io.read_image(os.path.join(
            voc_dir, 'SegmentationClass' ,f'{fname}.png'), mode))
    return features, labels

train_features, train_labels = read_voc_images(voc_dir, True)

   下面我们绘制前5个输入图像及其标签。 在标签图像中,白色和黑色分别表示边框和背景,而其他颜色则对应不同的类别。

n = 5
imgs = train_features[0:n] + train_labels[0:n]
imgs = [img.permute(1,2,0) for img in imgs]
d2l.show_images(imgs, 2, n);

在这里插入图片描述

1.2.2 构建字典(RGB颜色值和类名互相映射)
#@save
VOC_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]]

#@save
VOC_CLASSES = ['background', 'aeroplane', 'bicycle', 'bird', 'boat',
               'bottle', 'bus', 'car', 'cat', 'chair', 'cow',
               'diningtable', 'dog', 'horse', 'motorbike', 'person',
               'potted plant', 'sheep', 'sofa', 'train', 'tv/monitor']
  • 定义voc_colormap2label函数:构建从上述RGB颜色值到类别索引的映射
  • 定义voc_label_indices函数:将RGB值映射到在Pascal VOC2012数据集中的类别索引。
#@save
def voc_colormap2label():
    """构建从RGB到VOC类别索引的映射"""
    colormap2label = torch.zeros(256 ** 3, dtype=torch.long)
    for i, colormap in enumerate(VOC_COLORMAP):
        colormap2label[
            (colormap[0] * 256 + colormap[1]) * 256 + colormap[2]] = i
    """
    这一行是将RGB三通道像素值按照R*256*256+G*256+B的方法算成一个像素值,
    再把这个值作为字典索引,其value=1.2.3......
    """
    return colormap2label

#@save
def voc_label_indices(colormap, colormap2label):
    """将VOC标签中的RGB值映射到它们的类别索引"""
    colormap = colormap.permute(1, 2, 0).numpy().astype('int32')
    """colormap:任意一张读入的图片的RGB值,其前两维是batch和channel"""
    idx = ((colormap[:, :, 0] * 256 + colormap[:, :, 1]) * 256
           + colormap[:, :, 2])
    return colormap2label[idx]

例如,在第一张样本图像中,飞机头部区域的类别索引为1,而背景索引为0。

y = voc_label_indices(train_labels[0], voc_colormap2label())
y[105:115, 130:140], VOC_CLASSES[1]
"""
y是一张图片(tensor),矩阵非常大,这里只打印其中一部分
飞机对应字典的第一个类别,直接用 VOC_CLASSES[1]输出类别名看看
"""
(tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
         [0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
         [0, 0, 0, 0, 0, 0, 1, 1, 1, 1],
         [0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
         [0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
         [0, 0, 0, 0, 1, 1, 1, 1, 1, 1],
         [0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
         [0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
         [0, 0, 0, 0, 0, 0, 1, 1, 1, 1],
         [0, 0, 0, 0, 0, 0, 0, 0, 1, 1]]),
 'aeroplane')
1.2.3 数据预处理
  • 在语义分割中,我们一般是将图像裁剪为固定尺寸(使用图像增广中的随机裁剪transforms.RandomCrop,裁剪输入图像和标签(图片)的相同区域)
  • 之所以要RandomCrop裁剪是因为图片大小不一样。图片分类中我们可以把图片resize到统一大小。但是语义分割的标签是包含一个个像素的标签的,resize之后需要将预测的像素类别重新映射回原始尺寸的输入图像。 这样的映射可能不够精确,尤其在不同语义的分割区域。(resize拉伸的时候中间多的像素是通过插值法插进去的。而标号是不好插值的,飞机中间拉伸的标粉红色还是啥都不好操作,所以语义分割的图像不用resize)
#@save
def voc_rand_crop(feature, label, height, width):
    """随机裁剪特征feature和标签图像label"""
    
    """
    get_params允许裁剪之后的区域返回边框的坐标数值(边界框)
    *rect就是把边界框四个坐标展开,这样对图片和标号做同样的裁剪
	"""
    rect = torchvision.transforms.RandomCrop.get_params(
        feature, (height, width))
    feature = torchvision.transforms.functional.crop(feature, *rect)
    label = torchvision.transforms.functional.crop(label, *rect)
    return feature, label

imgs = []
for _ in range(n):
	#随机裁剪出200*300的区域
    imgs += voc_rand_crop(train_features[0], train_labels[0], 200, 300)

imgs = [img.permute(1, 2, 0) for img in imgs]
d2l.show_images(imgs[::2] + imgs[1::2], 2, n);

在这里插入图片描述

  • 通过VOCSegDataset类自定义语义分割数据集。数据集中部分图片尺寸可能小于随机裁剪所指定的输出尺寸,这些图片通过自定义的filter函数移除掉。
#@save
class VOCSegDataset(torch.utils.data.Dataset):
    """一个用于加载VOC数据集的自定义数据集"""

    def __init__(self, is_train, crop_size, voc_dir):
        self.transform = torchvision.transforms.Normalize(
            mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
        self.crop_size = crop_size
        features, labels = read_voc_images(voc_dir, is_train=is_train)
        self.features = [self.normalize_image(feature)
                         for feature in self.filter(features)]#去掉小图片后标准化
        self.labels = self.filter(labels)
        self.colormap2label = voc_colormap2label()#构造这个字典有一定开销,所以在init里面做了
        print('read ' + str(len(self.features)) + ' examples')

    def normalize_image(self, img):
    """像素值/255后标准化"""
        return self.transform(img.float() / 255)

    def filter(self, imgs):
    """去掉尺寸小于crop_size的图片"""
        return [img for img in imgs if (
            img.shape[1] >= self.crop_size[0] and
            img.shape[2] >= self.crop_size[1])]

    def __getitem__(self, idx):
        feature, label = voc_rand_crop(self.features[idx], self.labels[idx],
                                       *self.crop_size)
        """label的RGB值换成类别标号,例如aeroplane区域像素换成1"""
        return (feature, voc_label_indices(label, self.colormap2label))

    def __len__(self):
        return len(self.features)

下面开始读取数据集:

"""大部分图片大于(320,480)但是大不了太多"""
crop_size = (320, 480)
voc_train = VOCSegDataset(True, crop_size, voc_dir)
voc_test = VOCSegDataset(False, crop_size, voc_dir)
read 1114 examples
read 1078 examples
"""标图片分类一张1.2分
标目标检测一张1.2毛
语义分割一个个像素标就很贵了,而且很耗时所以数据集都很小。自动驾驶领域不差钱除外
"""

  设批量大小为64,我们定义训练集的迭代器。 打印第一个小批量的形状会发现:与图像分类或目标检测不同,这里的标签是一个三维数组

batch_size = 64
train_iter = torch.utils.data.DataLoader(voc_train, batch_size, shuffle=True,
                                    drop_last=True,
                                    num_workers=d2l.get_dataloader_workers())
for X, Y in train_iter:
    print(X.shape)
    print(Y.shape)
    break
torch.Size([64, 3, 320, 480])
torch.Size([64, 320, 480])
"""Y没有通道数3,因为RGB三通道数已经换成类别标号0,1,2..."""
1.2.4 整合所有组件,读取Pascal VOC2012数据集
#@save
def load_data_voc(batch_size, crop_size):
    """加载VOC语义分割数据集"""
    voc_dir = d2l.download_extract('voc2012', os.path.join(
        'VOCdevkit', 'VOC2012'))
    num_workers = d2l.get_dataloader_workers()
    train_iter = torch.utils.data.DataLoader(
        VOCSegDataset(True, crop_size, voc_dir), batch_size,
        shuffle=True, drop_last=True, num_workers=num_workers)
    test_iter = torch.utils.data.DataLoader(
        VOCSegDataset(False, crop_size, voc_dir), batch_size,
        drop_last=True, num_workers=num_workers)
    return train_iter, test_iter

二、转置卷积

参考《13.10. 转置卷积》哔哩哔哩视频

  一般的卷积神经网络,我们会将图片送入卷积层之后,逐步缩小图片的尺寸,增大感受野,提取不同尺度的特征。但是这样做对语义分割来说很麻烦,因为其输入图片和标号图片的像素标签是一一对应的。为了解决这一点,我们可以使用转置卷积。
  转置卷积:可以增加上采样中间层特征图的空间维度。说白了可以使卷积之后的特征图尺寸大于输入图像。一般操作是图片经过一系列卷积层缩小尺寸之后,加一个1×1卷积层减小channel,再用转置卷积层恢复图片大小。比如全卷积网络:
在这里插入图片描述

2.1 基本原理

在这里插入图片描述
在这里插入图片描述

2.2 转置卷积基本实现

  我们可以定义trans_conv函数实现转置卷积:

import torch
from torch import nn
from d2l import torch as d2l

def trans_conv(X, K):
"""输入矩阵X和卷积核矩阵K"""
    h, w = K.shape
    Y = torch.zeros((X.shape[0] + h - 1, X.shape[1] + w - 1))
    for i in range(X.shape[0]):
        for j in range(X.shape[1]):
            Y[i: i + h, j: j + w] += X[i, j] * K
    return Y

对比一下常规卷积的实现:
在这里插入图片描述

def corr2d(X, K):  #@save
    """计算二维互相关运算"""
    h, w = K.shape
    Y = torch.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1))
    for i in range(Y.shape[0]):
        for j in range(Y.shape[1]):
            Y[i, j] = (X[i:i + h, j:j + w] * K).sum()
    return Y

  所以可以看到:转置卷积通过卷积核“广播”输入元素,从而产生大于输入的输出。 (输入矩阵每个元素广播到卷积核K的大小,之后再常规卷积)

测试:

X = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
K = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
trans_conv(X, K)

tensor([[ 0.,  0.,  1.],
        [ 0.,  4.,  6.],
        [ 4., 12.,  9.]])
X = torch.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
K = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
corr2d(X, K)

tensor([[19., 25.],
        [37., 43.]])

nn.ConvTranspose2d可以用于四维张量的转置卷积:

X, K = X.reshape(1, 1, 2, 2), K.reshape(1, 1, 2, 2)
tconv = nn.ConvTranspose2d(1, 1, kernel_size=2, bias=False)
tconv.weight.data = K
tconv(X)

tensor([[[[ 0.,  0.,  1.],
          [ 0.,  4.,  6.],
          [ 4., 12.,  9.]]]], grad_fn=<ConvolutionBackward0>)

2.3 填充、步幅和多通道

  1. 填充
    与常规卷积不同,在转置卷积中,填充被应用于的输出(常规卷积将填充应用于输入)。 例如,当将高和宽两侧的填充数指定为1时,转置卷积的输出中将删除第一和最后的行与列。
tconv = nn.ConvTranspose2d(1, 1, kernel_size=2, padding=1, bias=False)
tconv.weight.data = K
tconv(X)

tensor([[[[4.]]]], grad_fn=<ConvolutionBackward0>)
  1. 步幅
    在转置卷积中,步幅被指定为中间结果(输出),而不是输出。如下图:
    在这里插入图片描述
tconv = nn.ConvTranspose2d(1, 1, kernel_size=2, stride=2, bias=False)
tconv.weight.data = K
tconv(X)

tensor([[[[0., 0., 0., 1.],
          [0., 0., 2., 3.],
          [0., 2., 0., 3.],
          [4., 6., 6., 9.]]]], grad_fn=<ConvolutionBackward0>)
  1. 多通道
    对于多个输入和输出通道,转置卷积与常规卷积以相同方式运作。 假设输入有 c i c_i ci个通道,且转置卷积为每个输入通道分配了一个 k h × k w k_h\times k_w kh×kw的卷积核张量。 当指定多个输出通道时,每个输出通道将有一个 c i × k h × k w c_i\times k_h\times k_w ci×kh×kw的卷积核。

2.4 常规卷积与转置卷积的配合使用

   假设现在有输入 X \mathsf{X} X,常规卷积层 f f f。我们创建一个超参数与 f f f相同(填充、步幅),输出通道数与 X \mathsf{X} X相同的转置卷积层 g g g。则有: Y = f ( X ) \mathsf{Y}=f(\mathsf{X}) Y=f(X) g ( Y ) g(Y) g(Y)的形状将与 X \mathsf{X} X相同。 下面的示例可以解释这一点:

X = torch.rand(size=(1, 10, 16, 16))
conv = nn.Conv2d(10, 20, kernel_size=5, padding=2, stride=3)
tconv = nn.ConvTranspose2d(20, 10, kernel_size=5, padding=2, stride=3)
tconv(conv(X)).shape == X.shape

True

2.5 转置卷积:矩阵变换

2.5.1 常规卷积的矩阵实现

  对于一个输入X(3,3),我们首先使用corr2d函数计算其卷积输出Y:

X = torch.arange(9.0).reshape(3, 3)
K = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
Y = d2l.corr2d(X, K)
Y

tensor([[27., 37.],
        [57., 67.]])

  接下来,我们考虑用矩阵来实现和卷积一样的结果。
  首先创建权重矩阵W,其形状为(4,9),其非0元素来自卷积核K:

def kernel2matrix(K):
    k, W = torch.zeros(5), torch.zeros((4, 9))
    k[:2], k[3:5] = K[0, :], K[1, :]
    W[0, :5], W[1, 1:6], W[2, 3:8], W[3, 4:] = k, k, k, k
    return W

W = kernel2matrix(K)
W

tensor([[1., 2., 0., 3., 4., 0., 0., 0., 0.],
        [0., 1., 2., 0., 3., 4., 0., 0., 0.],
        [0., 0., 0., 1., 2., 0., 3., 4., 0.],
        [0., 0., 0., 0., 1., 2., 0., 3., 4.]])

  逐行连结输入X,获得了一个长度为9的矢量。 然后,W的矩阵乘法和向量化的X给出了一个长度为4的向量。 重塑它之后,可以获得与上面的原始卷积操作所得相同的结果Y:我们刚刚使用矩阵乘法实现了卷积。

Y == torch.matmul(W, X.reshape(-1)).reshape(2, 2)

tensor([[True, True],
        [True, True]])
2.5.2 转置卷积的矩阵实现

我们将上面的常规卷积 2 × 2 2 \times 2 2×2的输出Y作为转置卷积的输入。 想要通过矩阵相乘来实现它,我们只需要将权重矩阵W的形状转置为 ( 9 , 4 ) (9, 4) (9,4):

Z = trans_conv(Y, K)
Z == torch.matmul(W.T, Y.reshape(-1)).reshape(3, 3)

tensor([[True, True, True],
        [True, True, True],
        [True, True, True]])

在这里插入图片描述

三、使用全卷积网络FCN进行语义分割

  第一节我们介绍过,语义分割是对图像中的每个像素分类。全卷积网络(fully convolutional network,FCN)通过引入转置卷积(transposed convolution),将中间层特征图的高和宽变换回输入图像的尺寸,实现了从图像像素到像素类别的变换,输出的类别预测与输入图像在像素级别上具有一一对应关系。

%matplotlib inline
import torch
import torchvision
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l

3.1 构造模型

  全卷积网络模型基本设计如下图所示:
在这里插入图片描述
  全卷积网络先使用卷积神经网络抽取图像特征,然后通过 1 × 1 1\times 1 1×1卷积层将通道数变换为类别个数,最后通过转置卷积层将特征图的高和宽变换为输入图像的尺寸。因此,模型输出与输入图像的高和宽相同,且最终输出通道包含了该空间位置像素的类别预测。
  简单说FCN就是使用转置卷积层替换CNN最后的全连接和全局平均池化层

  1. 选取卷积神经网络
    前面的卷积神经网络,我们使用在ImageNet数据集上预训练的ResNet-18模型,来提取图像特征。但是ResNet-18模型的最后两层(全局平均汇聚层和全连接层)是不要的。
pretrained_net = torchvision.models.resnet18(pretrained=True)
list(pretrained_net.children())[-3:]

[Sequential(
  (0): BasicBlock(
    (conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
    (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (relu): ReLU(inplace=True)
    (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
    (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (downsample): Sequential(
      (0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)
      (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (1): BasicBlock(
    (conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
    (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    (relu): ReLU(inplace=True)
    (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
    (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  )
),
AdaptiveAvgPool2d(output_size=(1, 1)),
Linear(in_features=512, out_features=1000, bias=True)]

  复制ResNet-18中大部分的预训练层到net,除了最后的全局平均汇聚层和最接近输出的全连接层。给定高度为320和宽度为480的输入,net的前向传播将输入的高和宽减小至原来的 1 / 32 1/32 1/32,即10和15。

#去掉最后两层构建一个新的net
net = nn.Sequential(*list(pretrained_net.children())[:-2])

X = torch.rand(size=(1, 3, 320, 480))
net(X).shape
torch.Size([1, 512, 10, 15])
  1. 变换通道,加入转置卷积层
  • 使用 1 × 1 1\times1 1×1卷积层将输出通道数转换为Pascal VOC2012数据集的类数(21类)(减少通道数,降低计算量)
  • 使用转置卷积层,从而将其变回输入图像的高和宽(将特征图的高度和宽度增加32倍)
  • 如果步幅为 s s s,填充为 s / 2 s/2 s/2(假设 s / 2 s/2 s/2是整数)且卷积核的高和宽为 2 s 2s 2s,转置卷积核会将输入的高和宽分别放大 s s s倍。
  • ( 320 − 64 + 16 × 2 + 32 ) / 32 = 10 (320-64+16\times2+32)/32=10 (32064+16×2+32)/32=10 ( 480 − 64 + 16 × 2 + 32 ) / 32 = 15 (480-64+16\times2+32)/32=15 (48064+16×2+32)/32=15,我们构造一个步幅为 32 32 32的转置卷积层,并将卷积核的高和宽设为 64 64 64,填充为 16 16 16(每次移动半个kernel,填充16是为了避免移动到最后一次还有部分没有计算)。
num_classes = 21#其实这个值在21-512之间都行。这里取21是为了计算简单,因为转置卷积层kernel=64计算量很大,当然最终是会损失一点精度。
net.add_module('final_conv', nn.Conv2d(512, num_classes, kernel_size=1))
net.add_module('transpose_conv', nn.ConvTranspose2d(num_classes, num_classes,
                                    kernel_size=64, padding=16, stride=32))

3.2 转置卷积层的初始化

  在图像处理中,我们有时需要将图像放大,即上采样(upsampling)。双线性插值(bilinear interpolation)是常用的上采样方法之一,它也经常用于初始化转置卷积层。

  为了解释双线性插值,假设给定输入图像,我们想要计算上采样输出图像上的每个像素。

  1. 将输出图像的坐标 ( x , y ) (x,y) (x,y)映射到输入图像的坐标 ( x ′ , y ′ ) (x',y') (x,y)上。例如,根据输入与输出的尺寸之比来映射。请注意,映射后的 x ′ x′ x y ′ y′ y是实数。
  2. 在输入图像上找到离坐标 ( x ′ , y ′ ) (x',y') (x,y)最近的4个像素。
  3. 输出图像在坐标 ( x , y ) (x,y) (x,y)上的像素依据输入图像上这4个像素及其与 ( x ′ , y ′ ) (x',y') (x,y)的相对距离来计算。

  双线性插值的上采样可以通过转置卷积层实现,内核由以下bilinear_kernel函数构造。
限于篇幅,我们只给出bilinear_kernel函数的实现,不讨论算法的原理。

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 = (torch.arange(kernel_size).reshape(-1, 1),
          torch.arange(kernel_size).reshape(1, -1))
    filt = (1 - torch.abs(og[0] - center) / factor) * \
           (1 - torch.abs(og[1] - center) / factor)
    weight = torch.zeros((in_channels, out_channels,
                          kernel_size, kernel_size))
    weight[range(in_channels), range(out_channels), :, :] = filt
    return weight

示例如下:
  构造一个将输入的高和宽放大2倍的转置卷积层,并将其卷积核用bilinear_kernel函数初始化。

conv_trans = nn.ConvTranspose2d(3, 3, kernel_size=4, padding=1, stride=2,
                                bias=False)
conv_trans.weight.data.copy_(bilinear_kernel(3, 3, 4));

  读取图像X,将上采样的结果记作Y。为了打印图像,我们需要调整通道维的位置。

img = torchvision.transforms.ToTensor()(d2l.Image.open('../img/catdog.jpg'))
X = img.unsqueeze(0)
Y = conv_trans(X)
out_img = Y[0].permute(1, 2, 0).detach()

  可以看到,转置卷积层将图像的高和宽分别放大了2倍。

d2l.set_figsize()
print('input image shape:', img.permute(1, 2, 0).shape)
d2l.plt.imshow(img.permute(1, 2, 0));
print('output image shape:', out_img.shape)
d2l.plt.imshow(out_img);
input image shape: torch.Size([561, 728, 3])
output image shape: torch.Size([1122, 1456, 3])

  在全卷积网络中,我们用双线性插值的上采样初始化转置卷积层。使用Xavier初始化 1 × 1 1\times 1 1×1卷积层参数。

W = bilinear_kernel(num_classes, num_classes, 64)
net.transpose_conv.weight.data.copy_(W);

3.3 读取数据集

  使用d2l.load_data_voc函数读取数据集,指定随机裁剪的输出图像的形状为 320 × 480 320\times 480 320×480:高和宽都可以被 32 32 32整除。

batch_size, crop_size = 32, (320, 480)
train_iter, test_iter = d2l.load_data_voc(batch_size, crop_size)
read 1114 examples
read 1078 examples

3.4 开始训练

现在我们可以训练全卷积网络了。
这里的损失函数和准确率计算与图像分类中的并没有本质上的不同,因为我们使用转置卷积层的通道来预测像素的类别,所以需要在损失计算中指定通道维。
此外,模型基于每个像素的预测类别是否正确来计算准确率。

def loss(inputs, targets):
    return F.cross_entropy(inputs, targets, reduction='none').mean(1).mean(1)#在高和宽上都做平均。等于是每张图片每个像素做一个均值。

num_epochs, lr, wd, devices = 5, 0.001, 1e-3, d2l.try_all_gpus()
trainer = torch.optim.SGD(net.parameters(), lr=lr, weight_decay=wd)
d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs, devices)
loss 0.454, train acc 0.860, test acc 0.851
230.4 examples/sec on [device(type='cuda', index=0), device(type='cuda', index=1)]

在这里插入图片描述

3.5 模型预测

在预测时,我们需要将输入图像在各个通道做标准化,并转成卷积神经网络所需要的四维输入格式。

def predict(img):
    X = test_iter.dataset.normalize_image(img).unsqueeze(0)
    pred = net(X.to(devices[0])).argmax(dim=1)
    return pred.reshape(pred.shape[1], pred.shape[2])

为了[可视化预测的类别]给每个像素,我们将预测类别映射回它们在数据集中的标注颜色。

def label2image(pred):
    colormap = torch.tensor(d2l.VOC_COLORMAP, device=devices[0])
    X = pred.long()
    return colormap[X, :]
  • 测试数据集中的图像大小和形状各异。由于模型使用了步幅为32的转置卷积层,因此当输入图像的高或宽无法被32整除时,转置卷积层输出的高或宽会与输入图像的尺寸有偏差。
  • 为了解决这个问题,我们可以在图像中截取多块高和宽为32的整数倍的矩形区域,并分别对这些区域中的像素做前向传播。请注意,这些区域的并集需要完整覆盖输入图像。
    当一个像素被多个区域所覆盖时,它在不同区域前向传播中转置卷积层输出的平均值可以作为softmax运算的输入,从而预测类别。
  • 为简单起见,我们只读取几张较大的测试图像,并从图像的左上角开始截取形状为 320 × 480 320\times480 320×480的区域用于预测。对于这些测试图像,我们逐一打印它们截取的区域,再打印预测结果,最后打印标注的类别。
voc_dir = d2l.download_extract('voc2012', 'VOCdevkit/VOC2012')
test_images, test_labels = d2l.read_voc_images(voc_dir, False)
n, imgs = 4, []
for i in range(n):
    crop_rect = (0, 0, 320, 480)
    X = torchvision.transforms.functional.crop(test_images[i], *crop_rect)
    pred = label2image(predict(X))
    imgs += [X.permute(1,2,0), pred.cpu(),
             torchvision.transforms.functional.crop(
                 test_labels[i], *crop_rect).permute(1,2,0)]
d2l.show_images(imgs[::3] + imgs[1::3] + imgs[2::3], 3, n, scale=2);

在这里插入图片描述
其实就是边缘难以预测。

3.6 小结

  • 全卷积网络先使用卷积神经网络抽取图像特征,然后通过 1 × 1 1\times 1 1×1卷积层将通道数变换为类别个数,最后通过转置卷积层将特征图的高和宽变换为输入图像的尺寸。
  • 在全卷积网络中,我们可以将转置卷积层初始化为双线性插值的上采样。

四、风格迁移

4.1 风格迁移简介

   在本节中,我们将介绍如何使用卷积神经网络,自动将一个图像中的风格应用在另一图像之上,即风格迁移(style transfer)。 这里我们需要两张输入图像:一张是内容图像,另一张是风格图像。 我们将使用神经网络修改内容图像,使其在风格上接近风格图像
在这里插入图片描述
   下图简单阐述了基于卷积神经网络的风格迁移方法。
在这里插入图片描述

  1. 首先,我们初始化合成图像,例如将其初始化为内容图像。
  2. 然后,我们选择一个预训练的卷积神经网络来抽取图像的特征
  3. 选择其中某些层的输出作为内容特征或风格特征。使合成图像即匹配内容特征也匹配风格特征(具体做法下一节代码有讲)
  4. 我们通过前向传播(实线箭头方向)计算风格迁移的损失函数,并通过反向传播(虚线箭头方向)迭代模型参数,即不断更新合成图像
  5. 注意:风格迁移模型中需要训练的的模型参数是合成图像(的像素值),而不是CNN模型的参数。CNN模型参数在训练中无须更新。
  6. 风格迁移常用的损失函数由3部分组成:
    1. 内容损失使合成图像与内容图像在内容特征上接近;
    2. 风格损失使合成图像与风格图像在风格特征上接近;
    3. 全变分损失则有助于减少合成图像中的噪点。
  7. 最后,当模型训练结束时,我们输出风格迁移的模型参数,即得到最终的合成图像

4.2 风格迁移简单实现

4.2.1读取内容和风格图像
%matplotlib inline
import torch
import torchvision
from torch import nn
from d2l import torch as d2l

d2l.set_figsize()
content_img = d2l.Image.open('../img/rainier.jpg')
d2l.plt.imshow(content_img);

在这里插入图片描述

style_img = d2l.Image.open('../img/autumn-oak.jpg')
d2l.plt.imshow(style_img);

在这里插入图片描述

4.2.2 定义预处理和后处理函数
  • 预处理函数preprocess:对输入图像在RGB三个通道分别做标准化,并将结果变换成卷积神经网络接受的输入格式。
  • 后处理函数postprocess:将输出图像中的像素值还原回标准化之前的值。由于每个像素的浮点数值在0到1之间,我们对小于0和大于1的值分别取0和1。
#这个均值和方差是从ImageNet里面来的
rgb_mean = torch.tensor([0.485, 0.456, 0.406])
rgb_std = torch.tensor([0.229, 0.224, 0.225])

def preprocess(img, image_shape):
    transforms = torchvision.transforms.Compose([
        torchvision.transforms.Resize(image_shape),
        torchvision.transforms.ToTensor(),
        torchvision.transforms.Normalize(mean=rgb_mean, std=rgb_std)])
    return transforms(img).unsqueeze(0)

def postprocess(img):
    img = img[0].to(rgb_std.device)
    #permute(1,2,0)是将第一维挪到最后,下一步又将其挪回来
    img = torch.clamp(img.permute(1, 2, 0) * rgb_std + rgb_mean, 0, 1)
    return torchvision.transforms.ToPILImage()(img.permute(2, 0, 1))
4.2.3 抽取图像特征
  1. 首先,我们使用基于ImageNet数据集预训练的VGG-19模型来抽取图像特征。
pretrained_net = torchvision.models.vgg19(pretrained=True)
  1. 定义内容特征层和风格特征层

   一般来说,越靠近输入层,越容易抽取图像的细节信息;反之,则越容易抽取图像的全局信息。
  VGG-19一共29层,为了避免合成图像过多保留内容图像的细节,我们选择VGG较靠近输出的层,即内容层,来输出图像的内容特征。另外,选择不同层的输出来匹配局部和全局的风格,作为风格层。VGG网络使用了5个卷积块。 实验中,我们选择第四卷积块的最后一个卷积层作为内容层,选择每个卷积块的第一个卷积层作为风格层。所以有:

style_layers, content_layers = [0, 5, 10, 19, 28], [25]

  此时,我们只需要输入层到内容层&风格层之间的所有层。构建一个新的net:

#去掉28之后的层
net = nn.Sequential(*[pretrained_net.features[i] for i in
                      range(max(content_layers + style_layers) + 1)])
  1. 抽取内容特征和风格特征

  定义extract_features函数抽取内容特征和风格特征。这里由于我们需要中间层的输出,因此需要逐层计算,并保留内容层和风格层的输出。

def extract_features(X, content_layers, style_layers):
"""抽取图片X的内容特征和风格特征"""

    contents = []
    styles = []
    for i in range(len(net)):
        X = net[i](X)
        if i in style_layers:
            styles.append(X)
        if i in content_layers:
            contents.append(X)
    return contents, styles
  • 定义了get_contents函数和get_styles函数,分别对内容图像抽取内容特征和对风格图像抽取风格特征。(因为在训练时无须改变预训练的VGG的模型参数,所以我们可以在训练开始之前就提取出内容特征和风格特征。 )
  • 由于合成图像是风格迁移所需迭代的模型参数,我们只能在训练过程中通过调用刚刚定义的extract_features函数来抽取合成图像的内容特征和风格特征。
def get_contents(image_shape, device):
    content_X = preprocess(content_img, image_shape).to(device)
    #只保留内容图片的内容特征
    contents_Y, _ = extract_features(content_X, content_layers, style_layers)
    return content_X, contents_Y

def get_styles(image_shape, device):
    style_X = preprocess(style_img, image_shape).to(device)
    #只保留风格图片的风格特征
    _, styles_Y = extract_features(style_X, content_layers, style_layers)
    return style_X, styles_Y
4.2.4 定义损失函数

风格迁移的损失函数, 由内容损失、风格损失和全变分损失3部分组成。

  1. 内容损失
    内容损失通过平方误差函数衡量合成图像与内容图像在内容特征上的差异。
    Y_hat:合成图片的内容特征(extract_features函数的内容层输出)
    Y;:内容图片的内容特征
def content_loss(Y_hat, Y):
    # 我们从动态计算梯度的树中分离目标:
    # 这是一个规定的值,而不是一个变量。
    return torch.square(Y_hat - Y.detach()).mean()
  1. 风格损失
  • 如何衡量一张图片的风格呢?这里认为风格是图片各个通道的通道内像素的统计信息,和通道之间的统计信息。所以两张图片的风格一样,不是每个通道的像素值一样,而是通道内和通道之间的统计信息相似就行。
  • 这些统计信息如何计算呢?可以通过匹配一阶(均值,认为是0)、二阶(方差,通过gram矩阵计算)、三阶等等统计信息。这里简单点只匹配一阶二阶统计信息
  • 计算时,将通道、高宽为(c,h,w)的图片转成(c,h×w)的矩阵格式(c个长hw的向量)。
    在这里插入图片描述
def gram(X):
    num_channels, n = X.shape[1], X.numel() // X.shape[1]
    X = X.reshape((num_channels, n))#图片高宽相乘得n
    return torch.matmul(X, X.T) / (num_channels * n)

  最终,风格损失是合成图片的风格特征,和风格图片的风格特征,之间的风格差异(gram)。gram_Y表示提前算好的计算好的风格图像的格拉姆矩阵。

def style_loss(Y_hat, gram_Y):
    return torch.square(gram(Y_hat) - gram_Y.detach()).mean()
  1. 全变分损失
    有时候,我们学到的合成图像里面有大量高频噪点,即有特别亮或者特别暗的颗粒像素。 一种常见的去噪方法是全变分去噪(total variation denoising)。
    在这里插入图片描述
def tv_loss(Y_hat):
#一种降噪算法:每个像素和周围像素的绝对值不要差太多
    return 0.5 * (torch.abs(Y_hat[:, :, 1:, :] - Y_hat[:, :, :-1, :]).mean() +
                  torch.abs(Y_hat[:, :, :, 1:] - Y_hat[:, :, :, :-1]).mean())
  1. 风格转移损失
    风格转移的损失函数是内容损失、风格损失和总变化损失的加权和。 通过调节这些权重超参数,我们可以权衡合成图像在保留内容、迁移风格以及去噪三方面的相对重要性
content_weight, style_weight, tv_weight = 1, 1e3, 10

def compute_loss(X, contents_Y_hat, styles_Y_hat, contents_Y, styles_Y_gram):
    # 分别计算内容损失、风格损失和全变分损失
    contents_l = [content_loss(Y_hat, Y) * content_weight for Y_hat, Y in zip(
        contents_Y_hat, contents_Y)]
        
    styles_l = [style_loss(Y_hat, Y) * style_weight for Y_hat, Y in zip(
        styles_Y_hat, styles_Y_gram)]
        
    tv_l = tv_loss(X) * tv_weight
    # 对所有损失再次加权求和
    l = sum(10 * styles_l + contents_l + [tv_l])
    return contents_l, styles_l, tv_l, l
4.2.5 初始化合成图像

  定义一个简单的模型SynthesizedImage,并将合成的图像视为模型参数。模型的前向传播只需返回模型参数即可:

class SynthesizedImage(nn.Module):
    def __init__(self, img_shape, **kwargs):
        super(SynthesizedImage, self).__init__(**kwargs)
        self.weight = nn.Parameter(torch.rand(*img_shape))#weight作为参赛才可以更新

    def forward(self):
        return self.weight

  定义get_inits函数。该函数创建了合成图像的模型实例,并将其初始化为图像X。风格图像在各个风格层的格拉姆矩阵styles_Y_gram将在训练前预先计算好。

def get_inits(X, device, lr, styles_Y):
    gen_img = SynthesizedImage(X.shape).to(device)
    gen_img.weight.data.copy_(X.data)
    trainer = torch.optim.Adam(gen_img.parameters(), lr=lr)
    styles_Y_gram = [gram(Y) for Y in styles_Y]
    return gen_img(), styles_Y_gram, trainer
4.2.6 训练模型

  在训练模型进行风格迁移时,我们不断抽取合成图像的内容特征和风格特征,然后计算损失函数。下面定义了训练循环。

def train(X, contents_Y, styles_Y, device, lr, num_epochs, lr_decay_epoch):
    X, styles_Y_gram, trainer = get_inits(X, device, lr, styles_Y)
    scheduler = torch.optim.lr_scheduler.StepLR(trainer, lr_decay_epoch, 0.8)
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[10, num_epochs],
                            legend=['content', 'style', 'TV'],
                            ncols=2, figsize=(7, 2.5))
    for epoch in range(num_epochs):
        trainer.zero_grad()
        contents_Y_hat, styles_Y_hat = extract_features(
            X, content_layers, style_layers)
        contents_l, styles_l, tv_l, l = compute_loss(
            X, contents_Y_hat, styles_Y_hat, contents_Y, styles_Y_gram)
        l.backward()
        trainer.step()
        scheduler.step()
        if (epoch + 1) % 10 == 0:
            animator.axes[1].imshow(postprocess(X))
            animator.add(epoch + 1, [float(sum(contents_l)),
                                     float(sum(styles_l)), float(tv_l)])
    return X

  训练时,首先将内容图像和风格图像的高和宽分别调整为300和450像素,用内容图像来初始化合成图像;

device, image_shape = d2l.try_gpu(), (300, 450)
net = net.to(device)
content_X, contents_Y = get_contents(image_shape, device)
_, styles_Y = get_styles(image_shape, device)
output = train(content_X, contents_Y, styles_Y, device, 0.3, 500, 50)

在这里插入图片描述
  我们可以看到,合成图像保留了内容图像的风景和物体,并同时迁移了风格图像的色彩。例如,合成图像具有与风格图像中一样的色彩块,其中一些甚至具有画笔笔触的细微纹理。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

动手深度学习13:计算机视觉——语义分割、风格迁移 的相关文章

随机推荐

  • mysql 字段值(字符串)累加

    mysql在更新记录时 需要在原来的值上在累加新的值 例如原来有条记录 id country a784829a c0dc 4cb6 88a9 8c376fab83a6 USA 现在更新更新country字段 在原值的基础上添加 UK 使其变
  • 申请Google Player帐号上传自己开发的App

    1 访问https play google com apps publish signup 2 输入个人信息 3 在选择国家 地区时 由于列表中没有中国 所以我们只能选择香港 注册Google Player开发帐号是需要支付25美元费用的
  • Unity中关于委托与事件的使用及区别

    一 前言 1 什么是委托 个人理解 委托是一种容器 容器里面放的是函数方法 而函数的形式各不相同 参数 返回值各不相同 所以你做委托之前 先得要定义好这个委托容器存放的函数的类型 即委托类型 定义了好了函数类型后 将函数加入到委托容器后 你
  • 【Ubuntu】右键菜单添加用vscode打开

    右键菜单添加自定义命令 本文以添加右键使用vscode打开为例 1 进入 local share nautilus scripts文件夹 cd local share nautilus scripts 2 创建文件 vim Open in
  • 关于工具trinity:syscall测试

    git地址 https github com kernelslacker trinity linux下对syscall的模糊测试 main函数位于trinity c中 int main int argc char argv int ret
  • PLC通讯协议【三菱】FX协议的报文格式和读写示例

    通过编程口通讯协议 具体可操作的软元件有 X Y M S T C D 通讯设置 必须设置为 波特率9600 偶校验 7位数据位 1停止位 否则无法通讯 一 报文结构 注意 通讯协议中的所有字符是用它们的十六进制ASCII码表示 如果有十进制
  • Unity 3D模型展示之模型高亮

    最终效果 1 导入插件Outline Effect 在Asset Store或者Package Manager中搜索Outline Effect 并导入插件 导入成功后 2 Outline Effect 的使用 Main Camera添加O
  • 浅谈伺服电机三种控制方式

    速度控制和转矩控制都是用模拟量来控制的 位置控制是通过发脉冲来控制的 具体采用什么控制方式要根据客户的要求 满足何种运动功能来选择 如果您对电机的速度 位置都没有要求 只要输出一个恒转矩 当然是用转矩模式 如果对位置和速度有一定的精度要求
  • Vue中Rule的使用

    日常的rule效验 能帮我们省很多时间去做if eles判断 下边记录一下我在入职新公司的使用 1 需要效验的字段 要在from item上家prop属性 可以和字段同名 2 在data中创建一个rules对象 绑定当前的from表单 并把
  • dev c++无法识别汇编代码_新手福利丨超详细的Tengine GEMM矩阵乘法汇编教程

    很多刚入门Tengine的开发者想研读Tengine汇编代码 却苦于没有好的汇编入门教程 没有大神带入门 自己看又看不懂 怎么办 福利来了 Tengine带来了一份超详细的gemm汇编教程 GEMM简介 什么是GEMM 它的英文全称是 GE
  • 常见服务知识点罗列--haproxy/keepalived

    一 haproxy 1 haproxy配置的主要模块 global defaults 主要功能 实现负载均衡 root k8s ha1 vim etc haproxy haproxy cfg listen k8s 6443 bind 192
  • Prometheus监控 controller-manager scheduler etcd

    用prometheus插件监控kubernetes控制平面 例如 您使用kubeadm构建k8s集群 然后kube控制器管理器 kube调度程序和etcd需要一些额外的工作来进行发现 create service for kube cont
  • 利用pandas生成csv文件和读取csv文件中的方法

    方法一 1 我构造了一个cont list 结构为列表嵌套字典 字典是每一个样本 类似于我们爬虫爬下来的数据的结构 2 利用pd DataFrame方法先将数据转换成一个二维结构数据 如下方打印的内容所示 cloumns指定列表 列表必须是
  • MySQL主从复制与读写分离

    目录 主从复制与读写分离 读写分离 MySQL 读写分离原理 读写分离分类 主从复制 mysq支持的复制类型 主从复制的工作过程 MySQL主从复制延迟 搭建 MySQL主从复制 搭建 MySQL读写分离 主从复制与读写分离 在实际的生产环
  • python拆解字典中包含list的数据,全排列全部组合

    效果就是把类似 my dict config 1 1 2 3 4 config 2 test config 3 4 config 4 a b config 5 a 这样的数据拆解为 config 2 test config 3 4 conf
  • vue中使用vue-touch

    如果想让vue能够监听移动端的上滑 下滑 左滑 点击等等动作 可以使用vue touch插件 vue touch的使用十分简单 首先在vue项目中安装vue touch npm install vue touch next save 然后在
  • Unknown column in ‘where clause‘ 报错异常

    这时候我们首先要想到肯定是sql出现问题 打印出sql语句 用查询来执行 明显 在需要查询的字段 应该是加上引号 正确查询如下 解决方法 修改拼接查询语句的函数 在用测试类测试 完美解决
  • Matlab实战系列(一)——一些常用的绘图语句

    之前一直用Python的matplotlib pyplot包画图 很少用matlab 这次作业想用一下发现很多调整画图版式的函数都不太会用 网上系统一点的代码也不多 于是写一篇这个记录一下 母函数plot plot lambda R col
  • python---三元表达式

    三元表达式适用于二选一的场景 其结构为 值1 if 条件 else 值2 条件如果成立则使用值1 if前面的数据 条件如果不成立则使用值2 else后面的数据 写一个电影系统 需要决定电影是否收费 is change input 是否收费
  • 动手深度学习13:计算机视觉——语义分割、风格迁移

    文章目录 一 语义分割 1 1 语义分割简介 1 2 Pascal VOC2012 语义分割数据集 1 2 1下载 读取数据集 1 2 2 构建字典 RGB颜色值和类名互相映射 1 2 3 数据预处理 1 2 4 整合所有组件 读取Pasc