文章目录
- 一、语义分割
- 1.1 语义分割简介
- 1.2 Pascal VOC2012 语义分割数据集
- 1.2.1下载、读取数据集
- 1.2.2 构建字典(RGB颜色值和类名互相映射)
- 1.2.3 数据预处理
- 1.2.4 整合所有组件,读取Pascal VOC2012数据集
- 二、转置卷积
- 2.1 基本原理
- 2.2 转置卷积基本实现
- 2.3 填充、步幅和多通道
- 2.4 常规卷积与转置卷积的配合使用
- 2.5 转置卷积:矩阵变换
- 2.5.1 常规卷积的矩阵实现
- 2.5.2 转置卷积的矩阵实现
- 三、使用全卷积网络FCN进行语义分割
- 3.1 构造模型
- 3.2 转置卷积层的初始化
- 3.3 读取数据集
- 3.4 开始训练
- 3.5 模型预测
- 3.6 小结
- 四、风格迁移
- 4.1 风格迁移简介
- 4.2 风格迁移简单实现
- 4.2.1读取内容和风格图像
- 4.2.2 定义预处理和后处理函数
- 4.2.3 抽取图像特征
- 4.2.4 定义损失函数
- 4.2.5 初始化合成图像
- 4.2.6 训练模型
一、语义分割
参考李沐动手深度学习《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
二、转置卷积
一般的卷积神经网络,我们会将图片送入卷积层之后,逐步缩小图片的尺寸,增大感受野,提取不同尺度的特征。但是这样做对语义分割来说很麻烦,因为其输入图片和标号图片的像素标签是一一对应的。为了解决这一点,我们可以使用转置卷积。
转置卷积:可以增加上采样中间层特征图的空间维度。说白了可以使卷积之后的特征图尺寸大于输入图像。一般操作是图片经过一系列卷积层缩小尺寸之后,加一个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时,转置卷积的输出中将删除第一和最后的行与列。
tconv = nn.ConvTranspose2d(1, 1, kernel_size=2, padding=1, bias=False)
tconv.weight.data = K
tconv(X)
tensor([[[[4.]]]], grad_fn=<ConvolutionBackward0>)
- 步幅
在转置卷积中,步幅被指定为中间结果(输出),而不是输出。如下图:
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>)
- 多通道
对于多个输入和输出通道,转置卷积与常规卷积以相同方式运作。 假设输入有个通道,且转置卷积为每个输入通道分配了一个的卷积核张量。 当指定多个输出通道时,每个输出通道将有一个的卷积核。
2.4 常规卷积与转置卷积的配合使用
,常规卷积层。我们创建一个超参数与相同(填充、步幅),输出通道数与相同的转置卷积层。则有:,的形状将与相同。 下面的示例可以解释这一点:
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 转置卷积的矩阵实现
我们将上面的常规卷积的输出Y作为转置卷积的输入。 想要通过矩阵相乘来实现它,我们只需要将权重矩阵W的形状转置为:
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 构造模型
全卷积网络模型基本设计如下图所示:
全卷积网络先使用卷积神经网络抽取图像特征,然后通过卷积层将通道数变换为类别个数,最后通过转置卷积层将特征图的高和宽变换为输入图像的尺寸。因此,模型输出与输入图像的高和宽相同,且最终输出通道包含了该空间位置像素的类别预测。
简单说FCN就是使用转置卷积层替换CNN最后的全连接和全局平均池化层
- 选取卷积神经网络
前面的卷积神经网络,我们使用在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
的前向传播将输入的高和宽减小至原来的,即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])
- 变换通道,加入转置卷积层
- 使用卷积层将输出通道数转换为Pascal VOC2012数据集的类数(21类)(减少通道数,降低计算量)
- 使用转置卷积层,从而将其变回输入图像的高和宽(将特征图的高度和宽度增加32倍)
- 如果步幅为,填充为(假设是整数)且卷积核的高和宽为,转置卷积核会将输入的高和宽分别放大倍。
- 且,我们构造一个步幅为的转置卷积层,并将卷积核的高和宽设为,填充为(每次移动半个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)是常用的上采样方法之一,它也经常用于初始化转置卷积层。
为了解释双线性插值,假设给定输入图像,我们想要计算上采样输出图像上的每个像素。
- 将输出图像的坐标映射到输入图像的坐标上。例如,根据输入与输出的尺寸之比来映射。请注意,映射后的和是实数。
- 在输入图像上找到离坐标最近的4个像素。
- 输出图像在坐标上的像素依据输入图像上这4个像素及其与的相对距离来计算。
双线性插值的上采样可以通过转置卷积层实现,内核由以下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初始化卷积层参数。
W = bilinear_kernel(num_classes, num_classes, 64)
net.transpose_conv.weight.data.copy_(W);
3.3 读取数据集
使用d2l.load_data_voc
函数读取数据集,指定随机裁剪的输出图像的形状为:高和宽都可以被整除。
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
运算的输入,从而预测类别。 - 为简单起见,我们只读取几张较大的测试图像,并从图像的左上角开始截取形状为的区域用于预测。对于这些测试图像,我们逐一打印它们截取的区域,再打印预测结果,最后打印标注的类别。
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 小结
- 全卷积网络先使用卷积神经网络抽取图像特征,然后通过卷积层将通道数变换为类别个数,最后通过转置卷积层将特征图的高和宽变换为输入图像的尺寸。
- 在全卷积网络中,我们可以将转置卷积层初始化为双线性插值的上采样。
四、风格迁移
4.1 风格迁移简介
在本节中,我们将介绍如何使用卷积神经网络,自动将一个图像中的风格应用在另一图像之上,即风格迁移(style transfer)。 这里我们需要两张输入图像:一张是内容图像,另一张是风格图像。 我们将使用神经网络修改内容图像,使其在风格上接近风格图像
下图简单阐述了基于卷积神经网络的风格迁移方法。
- 首先,我们初始化合成图像,例如将其初始化为内容图像。
- 然后,我们选择一个预训练的卷积神经网络来抽取图像的特征
- 选择其中某些层的输出作为内容特征或风格特征。使合成图像即匹配内容特征也匹配风格特征(具体做法下一节代码有讲)
- 我们通过前向传播(实线箭头方向)计算风格迁移的损失函数,并通过反向传播(虚线箭头方向)迭代模型参数,即不断更新合成图像
- 注意:风格迁移模型中需要训练的的模型参数是合成图像(的像素值),而不是CNN模型的参数。CNN模型参数在训练中无须更新。
- 风格迁移常用的损失函数由3部分组成:
- 内容损失使合成图像与内容图像在内容特征上接近;
- 风格损失使合成图像与风格图像在风格特征上接近;
- 全变分损失则有助于减少合成图像中的噪点。
- 最后,当模型训练结束时,我们输出风格迁移的模型参数,即得到最终的合成图像
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 抽取图像特征
- 首先,我们使用基于ImageNet数据集预训练的VGG-19模型来抽取图像特征。
pretrained_net = torchvision.models.vgg19(pretrained=True)
- 定义内容特征层和风格特征层
一般来说,越靠近输入层,越容易抽取图像的细节信息;反之,则越容易抽取图像的全局信息。
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)])
- 抽取内容特征和风格特征
定义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部分组成。
- 内容损失
内容损失通过平方误差函数衡量合成图像与内容图像在内容特征上的差异。
Y_hat:合成图片的内容特征(extract_features函数的内容层输出)
Y;:内容图片的内容特征
def content_loss(Y_hat, Y):
# 我们从动态计算梯度的树中分离目标:
# 这是一个规定的值,而不是一个变量。
return torch.square(Y_hat - Y.detach()).mean()
- 风格损失
- 如何衡量一张图片的风格呢?这里认为风格是图片各个通道的通道内像素的统计信息,和通道之间的统计信息。所以两张图片的风格一样,不是每个通道的像素值一样,而是通道内和通道之间的统计信息相似就行。
- 这些统计信息如何计算呢?可以通过匹配一阶(均值,认为是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()
- 全变分损失
有时候,我们学到的合成图像里面有大量高频噪点,即有特别亮或者特别暗的颗粒像素。 一种常见的去噪方法是全变分去噪(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())
- 风格转移损失
风格转移的损失函数是内容损失、风格损失和总变化损失的加权和。 通过调节这些权重超参数,我们可以权衡合成图像在保留内容、迁移风格以及去噪三方面的相对重要性
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)
我们可以看到,合成图像保留了内容图像的风景和物体,并同时迁移了风格图像的色彩。例如,合成图像具有与风格图像中一样的色彩块,其中一些甚至具有画笔笔触的细微纹理。