通过PyTorch实现风格迁移

一.环境及数据集准备

pytorch 1.1.0

torchvision 0.3.0

cuda 9.0

数据集用的是COCO2014的train2014训练集,使用ImageNet也可以

需要用到在ImageNet上预训练好的VGG16,模型文件为vgg16-397923af.pth

二.原理

​ 首先需要明白一点,深度学习之所以被称为“深度”,就在于它采用了深层的网络结构,网络的不同层学到的是图像不同层面的特征信息

​ 研究表明,几乎所有神经网络的第一层学习到的都是关于线条和颜色的信息(直观理解就是像素组成色彩,点组成线)。再往上,神经网络开始关注一些复杂的特征(比如拐角或特殊形状等),这些特征可以看成是低层次的特征组合。随着深度的加深,神经网络关注的信息逐渐抽象(例如有的卷积核关注嘴巴,有的关注眼睛等,以及关注对象之间的空间关系等)。

​ 在进行风格迁移时,并不要求生成图片的像素和输入图片中每一个像素都一样,而追求的是生成图片和输入图片具有相同的特征(比如原图是个人,我们希望风格迁移之后的图片依据能明显看出来是个人)。但这是不是就类似神经网络中最后的层的输出(差不多也就是最后的卷积核关注的特征)?但是这最后一层的特征的抽象程度太高,我们不可能只希望最后只从生成图片中看出来是个人,我们很是希望生成图片中能保存输入图片的部分细节信息(五官、动作等),这些信息相对来说抽象程度没那么高。因此我们使用中间某些层的特征作为目标,希望输入图片和风格迁移的生成图片在这些层的特征尽可能的相似,即将图片在深度模型中的中间某些层的输出作为图像的的知觉特征

​ 注:这就是下面要使用VGG-16中不同激活层(ReLU)输出的原因所在

一般使用Gram矩阵来表示图像的风格特征。对于每一张图片,卷积层的输出形状为C×H×W,C是卷积核的通道数,一般称为有C个卷积核,每个卷积核学习图像的不同特征。每一个卷积核输出的H×W代表这张图像的一个feature map(特征图),最后所有的卷积和输出组合成feature maps。通过计算每个feature map之间的相似性,可以得到图像的风格特征。

​ 对于一个C×H×W的feature maps F ,Gram Matrix的形状为C×C,其第i、j个元素Gi,j的计算方式定义如下:
G i , j = ∑ k F i k F j k G_{i,j} = \sum_kF_{ik}F_{jk} Gi,j=kFikFjk
​ 其中Fik代表第i个feature map的第k个像素点。

​ 关于Gram Matrix,要注意:

  • Gram Matrix的计算采用累积的形式,抛弃了空间信息。一张图片的像素随机打乱之后计算得到的Gram Matrix和原图的Gram Matrix一样。所以可以认为Gram Matrix抛弃了元素之间的空间信息。
  • Gram Matrix的结果与feature maps F的尺度无关,只与通道数有关。无论H、W的大小如何,最后Gram Matrix的形状都是C×C。
  • 对于一个C×H×W的feature maps,可以通过调整形状和矩阵乘法快速计算它的Gram Matrix,即先将F调整为C×(HW)的二维矩阵,然后再计算F×F^T,结果就是Gram Matrix。(这是因为矩阵C×(HW)的转置矩阵为(HW)×C,所以矩阵乘法C×(HW) * (HW)×C的结果就是C×C。)

​ 实践证明Gram Matrix的特点:注重风格纹理等特征,忽略空间信息。图像的空间信息在计算Gram Matrix时都被舍弃,但是纹理、色彩等风格信息被保存下来。Gram Matrix表征图像的风格特征在风格迁移、纹理合成等任务中的表现十分出众。

​ 风格迁移的图片想要做到逼真。第一是要生成的图片在内容、细节上尽可能地与输入的内容图片相似;第二要生成的图片在风格上尽可能地与风格图片相似。因此,下面会用损失content loss和style loss来衡量这两个指标。

​ 在下面我们会实现一种快速风格迁移算法——Fast Style Transfer,其更多被称为Fast Neural Style

​ Fast Neural Style专门设计了一个网络用来进行风格迁移,输入原图片,网络将自动生成目标图片。这个网络会将输入的风格图片训练成一个相对应的风格网络,接下来输入我们想进行转换的图片,很快就能完成一次风格迁移。

​ 在Fast Neural Style的网络结构中,x是输入图像。在风格迁移任务中yc=xys是风格图片,Image Transform Net fw是我们设计的风格迁移网络,针对输入的图像x,能够返回一张新的图像y**。**y在图像内容上与yc相似,但在风格上与ys相似。损失网络(Loss Network)不用训练,只用来计算知觉特征和风格特征。损失网络,采用在ImageNet上预训练好的VGG-16。

通过PyTorch实现风格迁移_第1张图片

​ VGG-16网络,从上到下有5个卷积块,两个卷积块间通过MaxPooling层区分。每个卷积块有2~3个卷积层,每一个卷积层后面都跟着一个ReLU激活层。

​ Fast Neural Style的训练步骤:

​ (1)输入一张图片xfw(风格迁移网络) 中得到 y^(生成的图片)。

​ (2)将 y^yc(就是x) 输入到Loss Network(VGG-16)中,计算它在relu3_3(表示第3个卷积块的第3个卷积层的,激活层)的输出,并计算它们之间的均方误差作为content loss(生成的图片在内容、细节上与输入的内容图片的相似程度)。

​ (3)将 y^ys(风格图片)输入到Loss Network中,计算它在 relu1_2relu2_2relu3_3relu4_4 的输出,再计算它们的Gram Matrix 的均方误差作为 style loss(生成的图片在风格上与风格图片的相似程度)。

​ (4)两个损失相加,再反向传播。更新 fw 的参数,固定Loss Network不动(即不更新损失网络的参数)。

​ (5)重复(1)到(4),训练 fw

三.使用PyTorch实现风格迁移

1.项目的文件组织目录

  • checkpoints/:用来保存模型。
  • data/:用来保存数据,可以通过直接或软连接的方式将数据保存在data文件夹下,这样方便之后通过torchvision的ImageFolder方法直接加载文件夹中的图片。
  • main.py:主函数,包括训练和测试。
  • PackedVGG.py:预训练好的VGG-16,为了提取中间层的输出,做了一些简化。
  • transformer_net.py:风格迁移网络。输入一张图片,输出一张图片。
  • utils.py:工具包,主要是计算Gram Matrix、加载风格图片和batch数据标准化等。

2.预训练的VGG-16

VGG16的网络结构图如下。

通过PyTorch实现风格迁移_第2张图片

PackedVGG.py

#coding:utf-8
 
import torch
import torch.nn as nn
from torchvision.models import vgg16
from collections import namedtuple

class Vgg16(nn.Module):
	def __init__(self):
		super(Vgg16, self).__init__()
		features = list(vgg16(pretrained=True).features[:23]) # 把预训练的vgg16的前23层放在一个列表里,vgg16总共31层(除了全连接层)
		# features的第3,8,15,22层分布是:relu1_2,relu2_2,relu3_3,relu4_3
		self.features = nn.ModuleList(features).eval() # 不更新参数

	def forward(self, x):
		results = [] # 结果列表
		for ii,model in enumerate(self.features): # 获取3,8,15,22层的relu的输出
			x = model(x)
			if ii in {3, 8, 15, 22}:
				results.append(x)

		# namedtuple创建一个具名元组,参数有,typename代表元组名称,field_names代表元组中元素的名称,rename当元素名称中有Python的关键字,则必须设置为True
		vgg_outputs = namedtuple("VggOutputs",['relu1_2','relu2_2','relu3_3','relu4_3'])
		# * + results表示把列表中的数据全部提出来,等于去掉最外层的[]
		return vgg_outputs(*results) # 返回一个具名元组,元组的元素是3,8,15,22relu层的输出

上边我们在网络前向传播的过程中获得了中间层的输出,并保存下来,其他的层不再需要,节省了空间。

​ 在torchvision中,VGG的实现由两个nn.Sequential对象组成。第一个是features,包括卷积、激活和池化等层,用来提取图像特征。另一个是classifier,包含全连接层,用来分类。上边通过vgg.features直接获得对应的nn.Sequential对象。这样在前向传播时,当计算完指定层的输出后,就将结果保存于一个list中,然后再使用namedtuple进行名称绑定,这样可以通过output.relu1_2访问第一个元素,方便又直观。当然也可以利用layer.register_forward_hook的方式来获取相应层的输出,但是在本例中相对比较麻烦。

3.实现风格迁移网络

​ 风格迁移网络,参考了PyTorch的官方示例,其网络结构如下图所示。

通过PyTorch实现风格迁移_第3张图片

​ 图中(a)是网络的总体结构,左边(d)是一个残差单元的结构图,右边(b)和(c)分别是下采样和上采样单元的结构图。网络结构总结有以下几个特点。

  • 先下采样,然后上采样,这种做法使计算量变小。
  • 使用残差结构使网络变深。
  • 边缘补齐的方式不再是传统的补0,而是采用一种被称为Reflection Pad的补齐策略:上下左右反射边缘的像素进行补齐。
  • 上采样不再使用传统的ConvTransposed2d,而是先用Upsample,然后用Conv2d,这中做法能避免Checkerboard Artifacts现象(棋盘效应)。
  • Batch Normalization全部改成Instance Normalization
  • 网络中没有全连接层,线性操作是卷积,因此对输入和输出图片的尺寸没有要求,这里我们输入和输出图片的尺寸都是3×256×256(其他尺寸的一样可以,可以自定义做修改)。

transformer_net.py

#coding:utf-8
 
import torch as t
from torch import nn
import numpy as np


class TransformerNet(nn.Module):
	"""
	InstanceNorm:一个channel内做归一化,算H*W的均值,用在风格化迁移;
	因为在图像风格化中,生成结果主要依赖于某个图像实例,所以对整个batch归一化不适合图像风格化中,因而对HW做归一化。可以加速模型收敛,并且保持每个图像实例之间的独立。
	affine: 布尔值,当设为true,给该层添加可学习的仿射变换参数。
	仿射变换即进行平移、旋转、转置、缩放等操作。

	"""
	def __init__(self):
		super(TransformerNet, self).__init__()

		# 下卷积层
		self.initial_layers = nn.Sequential(
			ConvLayer(3, 32, kernel_size=9, stride=1),
			nn.InstanceNorm2d(32, affine=True),
			nn.ReLU(True),
			ConvLayer(32, 64, kernel_size=3, stride=2), # stride为2,使得特征图尺寸缩小1/2
			nn.InstanceNorm2d(64, affine=True),
			nn.ReLU(True),
			ConvLayer(64, 128, kernel_size=3, stride=2),
			nn.InstanceNorm2d(128, affine=True),
			nn.ReLU(True)
		)

		# Residual layers(残差层)
        self.res_layers = nn.Sequential(
			ResidualBlock(128),
			ResidualBlock(128),
			ResidualBlock(128),
			ResidualBlock(128),
			ResidualBlock(128)
		)

		# Upsampling Layers(上卷积层)
		self.upsample_layers = nn.Sequential(
			UpsampleConvLayer(128, 64, kernel_size=3, stride=1, upsample=2), # upsample取2是因为stride取1,为了使特征图的尺寸扩大2倍
			nn.InstanceNorm2d(64, affine=True),
			nn.ReLU(True),
			UpsampleConvLayer(64, 32, kernel_size=3, stride=1, upsample=2),
			nn.InstanceNorm2d(32, affine=True),
			nn.ReLU(True),
			ConvLayer(32, 3, kernel_size=9, stride=1)
		)

	def forward(self, x):
		out = self.initial_layers(x) # 下采样的卷积层
		out = self.res_layers(out) # 深度残差层
		out = self.upsample_layers(out) # 上采样的卷积层
		return out

    
# 卷积模块
class ConvLayer(nn.Module):
	"""
	add ReflectionPad for Conv
	这里使用了边界反射填充

	"""
	def __init__(self, in_channels, out_channels, kernel_size, stride):
		super(ConvLayer, self).__init__()
		# 计算反射填充的层数
		reflection_padding = int(np.floor(kernel_size / 2)) # np.floor 返回不大于输入参数的最大整数。

		# 定义反射填充层  H(out) = H(in) + paddingTop + paddingBottom 
        # 高加上下,宽加左右,平常我们的tensor是(B,C,H,W) 
		self.reflection_pad = nn.ReflectionPad2d(reflection_padding) # ReflectionPad2d的参数有四个,比如传一个元组(3,3,5,5) last right top bottom,只传一个数表示上下左右都是同一个数

		# 定义卷积层
		self.conv2d = nn.Conv2d(in_channels, out_channels, kernel_size, stride)

	def forward(self, x):
		# 在经过卷积层前先给x加个反射填充层,例如例如,(64,3,220,220) 过(3,3,5,5)得到(64,3,230,226),似乎我们一般都是上下左右加同一个数,做到镜像
		out = self.reflection_pad(x) # 这里上下左右加同一个数,使得特征图的H和W增大了
		out = self.conv2d(out)
		return out
    
    
# 上采样模块
class UpsampleConvLayer(nn.Module):
	"""
	这里也使用了边界反射填充
	先上采样,然后做一个卷积(Conv2d),而不是采用ConvTranspose2d,这种效果更好,为了避免棋盘效应
	比如,这里的kernel_size取3,stride取1,upsample取2,输入x的尺寸为2×2
	那么,经过nn.functional.interpolate()后特征图变成4×4
	经过reflection_pad()后特征图变成6×6,再通过conv2d()后特征图恢复到4×4
	
	"""
	def __init__(self, in_channels, out_channels, kernel_size, stride, upsample=None):
		super(UpsampleConvLayer, self).__init__()

		self.upsample = upsample # 上采样的标志,用来表示特征图扩大的倍数

		# 下面同上面的ConvLayer
		reflection_padding = int(np.floor(kernel_size / 2)) # 计算反射填充的层数
		self.reflection_pad = nn.ReflectionPad2d(reflection_padding)
		self.conv2d = nn.Conv2d(in_channels, out_channels, kernel_size, stride)

	def forward(self, x):
		if self.upsample: # 判断是否上采样
			# interpolate实现插值和上采样,插值算法取决于参数mode的设置,默认为最近邻nearest,scale_factor指定输出为输入的多少倍数
			x = t.nn.functional.interpolate(x, scale_factor=self.upsample)
			
		out = self.reflection_pad(x)
		out = self.conv2d(out)
		return out

    
# 残差层
class ResidualBlock(nn.Module):
    """
    这里kernel_size取3,使得reflection_padding反射填充的层数取1,
    通过reflection_pad层就会给原特征图H和C各增加2,
    再经过一个(k=3,p=0,s=1)的卷积后特征图的H和C又各减去2,
    因此维持了经过self.conv1层的特征图的尺寸不变。
    由此,可以明白,这里的残差网络加深了网络,且没有改变原本特征图的尺寸。
    """
	def __init__(self, channels):
		super(ResidualBlock, self).__init__()
		self.conv1 = ConvLayer(channels, channels, kernel_size=3, stride=1) 
		self.in1 = nn.InstanceNorm2d(channels, affine=True)
		self.conv2 = ConvLayer(channels, channels, kernel_size=3, stride=1)
		self.in2 = nn.InstanceNorm2d(channels, affine=True)
		self.relu = nn.ReLU()

	def forward(self, x):
		identity = x # 先将本体保存下来
		out = self.conv1(x)
		out = self.in1(out)
		out = self.relu(out)
		out = self.conv2(out)
		out = self.in2(out)
		out = out + identity # 将原本的x和最后的out相加在一次,实现残差
		return out # 这里或许可以再通过一个激活层,这样self.relu(out),但我没有这样实验过

​ **nn.functional.interpolate()**在插值算法上可以使用的上采样算法,有’nearest’,‘linear’,‘bilinear’,‘bicubic’,‘trilinear’和’area’,默认采用’nearest’。

​ 可以这样直观的观察interpolate()是如何使用的

input = torch.arange(1, 5, dtype=torch.float32).view(1, 1, 2, 2)
input
tensor([[[[1., 2.],
          [3., 4.]]]])

x = F.interpolate(input, scale_factor=2, mode='nearest')
x
tensor([[[[1., 1., 2., 2.],
          [1., 1., 2., 2.],
          [3., 3., 4., 4.],
          [3., 3., 4., 4.]]]])

x = F.interpolate(input, scale_factor=2, mode='bilinear', align_corners=True)
x
tensor([[[[1.0000, 1.3333, 1.6667, 2.0000],
          [1.6667, 2.0000, 2.3333, 2.6667],
          [2.3333, 2.6667, 3.0000, 3.3333],
          [3.0000, 3.3333, 3.6667, 4.0000]]]])

想了解PyTorch实现上采样的方法和原理,可以参考这个博客。

4.工具包

工具包里实现了计算Gram Matrix加载风格图片batch数据标准化

我们还可以在其中实现用于可视化的工具。

utils.py

#coding:utf-8
 
# 工具类
from itertools import chain
import torch as t
import torchvision as tv
import numpy as np

# 这里的均值和标准差不是0.5和0.5,而是使用他人专门计算的ImageNet上所有图片的均值和标准差
# 这更符合真实世界图片的分布,效果也比都为0.5好
IMAGENET_MEAN = [0.485,0.456,0.406] # 均值
IMAGENET_STD = [0.229,0.224,0.225] # 方差

def gram_matrix(y):
	"""
	使用Gram矩阵来表示图像的风格特征
	输入B,C,H,W
	输出B,C,C

	"""
	(b,ch,h,w) = y.size() # 比如1,8,2,2
	features = y.view(b,ch,w*h)  # 得到1,8,4
	features_t = features.transpose(1,2) # 调换第二维和第三维的顺序,即矩阵的转置,得到1,4,8
	gram = features.bmm(features_t) / (ch*h*w) # bmm()用来做矩阵乘法,及未转置的矩阵乘以转置后的矩阵,得到的就是1,8,8了
    # 由于要对batch中的每一个样本都计算Gram Matrix,因此使用bmm()来计算矩阵乘法,而不是mm()
	return gram


def get_style_data(path):
    """
    加载风格图片,
    输入: path, 文件路径
    返回: tensor 形状为1*c*h*w, 分布大约在-2~2
    """
    # 数据预处理
    style_transform = tv.transforms.Compose([
        tv.transforms.ToTensor(),
        tv.transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
    ])

    style_image = tv.datasets.folder.default_loader(path) # 加载数据
    style_tensor = style_transform(style_image) # 图片的tensor形状为 c*h*w
    return style_tensor.unsqueeze(0) # 增加一维,变成1*c*h*w


def normalize_batch(batch):
    """
    batch数据标准化
    输入: b,c,h,w  将分布在0~255的图片进行标准化
    输出: b,c,h,w  大约-2~2
    """
    mean = batch.data.new(IMAGENET_MEAN).view(1, -1, 1, 1) # .new()等于创建type和device与batch.data一样的tensor,不带参的new()无内容
    std = batch.data.new(IMAGENET_STD).view(1, -1, 1, 1)
    mean = (mean.expand_as(batch.data)) # 表示让mean变成和batch.data一样形状的tensor
    std = (std.expand_as(batch.data)) 
    return (batch / 255.0 - mean) / std # 返回标准化处理后的batch
上边new()中给参数,是在添加噪声,并且将数据维度变成(1,-1,1,1),比如64×64就变成了1×4096×1×1,其他维度变成1,那么-1位置就要增加其他维度的值

​ 比如 (3,1).expand_as(3,4) 形状就从(3,1)变成了(3,4),不够的数据,就重复扩充,比如**[[1],[2],[3]] -> [[1,1,1,1],[2,2,2,2],[3,3,3,3]]**。这里是为了让单独的mean和std扩充成batch形状,方便之后batch中每张图片的标准化,这样矩阵的计算就省去了单独一张图片一张图片的标准化了。

5.训练和测试

main.py

#coding:utf-8

# 风格迁移的主程序
import torch as t
import torchvision as tv
from torch.utils import data
from torch.nn import functional as F
import tqdm
import time
import os

mean = [0.485,0.456,0.406] # 平均
std = [0.229,0.224,0.225] # 方差

# 定义各项参数类
class Config:
	image_size = 256 # 图片大小
	batch_size = 8 # 批处理数
	data_root = 'data/coco2014/train2014/'
	num_workers = 4 # 多线程加载数据
	use_gpu = True # 默认使用GPU

	style_path = 'style.jpeg' # 风格图片存放的路径

	lr = 1e-3 # 学习率
	plot_every = 100 # 每10个batch可视化一次

	epoches = 2 # 训练轮数

	content_weight = 1e5 # content_loss的权重
	style_weight = 1e10 # style_loss的权重

	model_path = None # 预训练模型的路径
	save_path = 'imgs'
	content_path = 'content.jpg' # 需要进行风格迁移的图片
	result_path = 'output.png' # 风格迁移后结果的保存路径
    
    
# 定义训练过程
def train(**kwargs):
	opt = Config() # 加载参数

	# 加载数据和数据预处理
	transforms = tv.transforms.Compose([
		tv.transforms.Resize(opt.image_size),
		tv.transforms.CenterCrop(opt.image_size),
		tv.transforms.ToTensor(),
		tv.transforms.Lambda(lambda x:x*255)
	])
	dataset = tv.datasets.ImageFolder(opt.data_root,transforms)
	dataloader = data.DataLoader(dataset,opt.batch_size,num_workers=opt.num_workers,drop_last=True)
	print("Dataset:",len(dataset),"Dataloader:",len(dataloader)) 
    # Dataset: 82783 Dataloader: 10347
    
	# 转换网络
	transformer = TransformerNet().cuda()
	if opt.model_path:
		print("转换模型参加加载成功!")
		transformer.load_state_dict(t.load(opt.model_path,map_location=lambda _s,_:_s))

	# 损失网络Vgg16
	vgg = Vgg16().cuda().eval() 
	for param in vgg.parameters():
		param.requires_grad = False # 不反向传播更新vgg的参数

	# 优化器
	optimizer = t.optim.Adam(transformer.parameters(),opt.lr)


	# 获取风格图片的数据
	style = get_style_data(opt.style_path)
	style = style.cuda()
    
    # 风格图片的gram矩阵
	with t.no_grad():
		features_style = vgg(style) # vgg()只返回了 3,8,15,22的relu层的输出
		gram_style = [gram_matrix(y) for y in features_style] # 分别计算这些层的Gram Matrix
    
	now = time.time()
	for epoch in range(opt.epoches):
		c_loss = 0
		s_loss = 0
		for ii,(x,_) in tqdm.tqdm(enumerate(dataloader)):
			# 训练
			optimizer.zero_grad() # 梯度清零
			x = x.cuda()
			y = transformer(x) # 得到风格迁移后的图片
            # 对x和y(即yc和y^)进行batch标准化处理
			y = normalize_batch(y)
			x = normalize_batch(x)
            # 获得x和y在vgg上那四层的输出
			features_y = vgg(y)
			features_x = vgg(x)

			# content loss 内容loss,只用到了relu3_3(在原理中有介绍)
			content_loss = opt.content_weight * F.mse_loss(features_y.relu3_3,features_x.relu3_3)

			# style loss 风格loss,这里四层relu都用到了
			style_loss = 0
			for ft_y,gm_s in zip(features_y,gram_style):
				gram_y = gram_matrix(ft_y)
				style_loss += F.mse_loss(gram_y,gm_s.expand_as(gram_y))
			style_loss *= opt.style_weight

			total_loss = content_loss + style_loss
			total_loss.backward() # 反向传播
			optimizer.step() # 更新参数

			# 损失累加
			c_loss += content_loss.item()
			s_loss += style_loss.item()
            
            if ii%opt.plot_every == 0:
				# 每隔一定的batch保存风格迁移后的图片,也可以顺便把原图片也保存下来,方便对比
				tv.utils.save_image((y.data.cpu()[0]*0.225+0.45).clamp(min=0,max=1),'%s/%s_output_%s.png'%(opt.save_path,epoch,ii),normalize=True,range=(-1,1))
				# tv.utils.save_image((x.data.cpu()[0]*0.225+0.45).clamp(min=0,max=1),'%s/%s_input_%s.png'%(opt.save_path,epoch,ii),normalize=True,range=(-1,1))
			if ii%1000 == 0: # 每隔一定batch打印一次训练信息
				print("Epoch:{},C_Loss:{:.6f},S_Loss:{:.6f},Time:{:.4f}s".format(epoch,c_loss/8000,s_loss/8000,time.time()-now))
                
        # 保存模型
		t.save(transformer.state_dict(),'checkpoints/%s_style.pth'%epoch)

# 开始训练 
train()


# 测试
def stylize(**kwargs):
	with t.no_grad():
		opt = Config()

		# 加载要进行风格迁移的图片,并进行预处理
		content_image = tv.datasets.folder.default_loader(opt.content_path)
		content_transform = tv.transforms.Compose([
			tv.transforms.ToTensor(),
			tv.transforms.Lambda(lambda x:x.mul(255)) # 使用transforms.Lambda封装其为transforms策略,mul()也是矩阵的一种点乘操作,要求操作的两个矩阵维度必须一样
		])
		content_image = content_transform(content_image)
		content_image = content_image.unsqueeze(0).cuda().detach() # upsqueeze(0)增加了一个维度,使c×h×w变成了1×c×h×w

		# 模型
		style_model = TransformerNet().eval() # map_location表示将GPU保存的模型加载到CPU上
		style_model.load_state_dict(t.load(opt.model_path,map_location=lambda _s,_:_s))
		style_model = style_model.cuda() # 或这样map_location=lambda storage, loc: storage

		# 风格迁移与保存
		output = style_model(content_image)
		output_data = output.cpu().data[0]
		tv.utils.save_image(((output_data/255)).clamp(min=0,max=1),opt.result_path)

stylize()

为上面代码说明几点:

  • 图片的每个像素的取值范围为0~255。
  • 调用torchvision的transforms.ToTensor()操作,像素会被转换到0~1。
  • 标准化(减均值后除以标准差),这里使用的均值和标准差为[0.485,0.456,0.406]和[0.229,0.224,0.225]。可以估算这时图片的分布范围大概在 (0-0.4845)/0.229≈**-2.1和 (1-0.406)/0.225≈2.7**之间。尽管这是它的分布在-2.1~2.7,但是它的均值接近0,标准差接近1,采用ImageNet图片的均值和标准差作为标准化参数的目的是图像各个像素的分布接近标准分布。
  • VGG-16网络的输入图像数值大小为使用ImageNet均值和标准差进行标准化之后的图片数据,即-2.1~2.7。
  • TransformerNet网络的输入图片的像素值是0255,输出的像素值也希望是0255,但是由于输出没有做特殊处理,所以可能出现小于0和大于255的像素。

​ 另外除了直接调用train()和stylize()还可以通过终端的方式改变Config()类中的配置的值的方式来灵活运行训练和测试。

if __name__ == '__main__':
    import fire
	
	fire.Fire()
    
# 这样写需要先注释掉上面的train()和stylize()
# 然后通过终端来运行训练和测试过程,例如
python main.py train --style_path=XXX.jpeg

python main.py stylize --model_path='checkpoints/XXX.pth' --content_path=XXX.jpg --result_path=XXX.png --use_gpu=False # 这里字符串的引号是可以省略的

6.结果分析

风格图片1

通过PyTorch实现风格迁移_第4张图片

原图1
通过PyTorch实现风格迁移_第5张图片

原图2
通过PyTorch实现风格迁移_第6张图片

迁移后的图片
通过PyTorch实现风格迁移_第7张图片
可以看到效果很是不错,风格图片的风格特征被很好的学习到,且原图的知觉特征也很明显。

下面换一个风格图片,梵高的风格

通过PyTorch实现风格迁移_第8张图片

迁移后的图片:
通过PyTorch实现风格迁移_第9张图片

也还不错,把风格图片的颜色搭配学到了,不过艺术风格还差点意思。大家可以尝试训练其他风格的图片。
另外需要提出的一点,上面训练2个epoch花费了大约1.6个小时,是放在Kaggle上训练的,其用的GPU应该是Nvida Tesla P100。这不用我吹了,可以想想有多香把。

四.总结

​ 风格迁移非常的有趣,可以把我们的图片转换成我们喜欢的风格,且快速完成转换的过程。

​ 我们上面实现的是Fast Neural Style,在其之前还有风格迁移开山之作Neural Style,但是Neural Style在进行风格迁移时需要花费几十分钟甚至几个小时的训练,但是其生成图片的效果会比Fast Neural Style好上一点。不过,速度是个大硬伤,我们总喜欢很快就能看到满意的结果。

​ 除了Neural Style和Fast Neural Style,还有别的有趣又吸引人的风格迁移项目。

​ Adobe的图片风格深度迁移(Deep Photo Style Transfer),还有CycleGAN,它们在风格迁移上表现的尤为出色,比Fast Neural Style的效果要好上许多。其中,CycleGAN的网络结构和Fast Neural Style的transformer类似,但它采用的是GAN的训练方式,能够实现风格的双向转换(即原图片转换成风格图片,风格图片转换成原图片的风格)。这个我会在接下来抽空去接触并实践一下,有趣的东西还是要多取尝试的。

五.参考

深度学习框架PyTorch入门与实践(https://github.com/chenyuntc/pytorch-book/tree/master/chapter8-%E9%A3%8E%E6%A0%BC%E8%BF%81%E7%A7%BB(Neural%20Style))

Deconvolution and Checkerboard Artifacts(https://www.jianshu.com/p/36ff39344de5)

你可能感兴趣的:(PyTorch)