24/11/22 项目拆解 艺术风格转移
我们有时候想把两种艺术风格整合,创造更具艺术特色的艺术品,人很难办到,但是人工智能可以,比如下面将艺术画的风格转移到照片上。
我们先来初步了解一下实现上述功能的数学原理
所谓艺术风格,其实就是边缘,颜色,纹理等各种要素的组合,因此可以通过卷积网络对图片识别后,所得的结果就已经包含了图片的艺术风格,因此我们可以通过卷积网络将构成图片风格的各种要素抽取出来。
风格转移这一过程可以通过以下几个关键步骤实现:
-
内容和风格的表示:
- 内容表示:通常使用深度卷积神经网络(如VGG19)的中间层来提取图像的内容特征。网络的深层能够捕捉到图像的高级语义信息,即内容。
- 风格表示:风格则通过计算特征图的格拉姆矩阵(Gram matrix)来表示,它描述了特征图之间的相关性,即纹理和颜色分布等风格信息。
-
损失函数:
- 风格迁移的目标是最小化一个损失函数,该函数由两部分组成:风格损失和内容损失。
- 风格损失:计算生成图像与风格图像在每个层的特征图的格拉姆矩阵之间的差异。
- 内容损失:计算生成图像与内容图像在相应层的特征图之间的差异。
- 通过最小化这些损失,生成图像逐渐接近目标风格和内容。
-
优化过程:
- 从一个随机噪声图像或内容图像开始,使用梯度下降算法不断优化图像,以最小化损失函数。
- 通常使用L-BFGS算法或Adam优化器进行优化。
-
总变差损失(Total Variation Loss):
- 为了保持生成图像的平滑性,通常会添加一个总变差损失项,它惩罚图像中的高频信息,即图像中的噪声和细节
网络从输入图像中抠出一小片做卷积运算,之后得到一个向量。抠出的一小片是二维的,由于每一个像素点被计算成一个向量,因此卷积运算后得到一个三维的”一小块“。
我们假设抠出的这一小片映射到第 L 层后,对应的”小块“其宽度为 W ,高度为 H ,深度为NL,如果我们将它压扁,从三维降为二维,把它的一个面积为 W * H平面拉长成一条长度为 ML = W * H的一条线,于是一个三维向量就转化为二维向量,用数学表达式表示为:
同时我们用表示把深度为 i 的切片拉成一条直线后的第 j 个元素。通过前面讲述的卷积操作的内容就知道,包含了输入图片的某些内容。如果反过来先想,给定了 后,我们如何还原它所蕴含的画面信息呢?
做法是拿一张白色图片,每个像素点的值都是255,让它经过卷积网络运算后,我们拿出同处于 l 层的结果,这个结果记为。接着调整白色图片像素点的值,使得与不断接近,当他们足够接近时,白色图片的像素点发生转变后,就呈现出与输入图片相似的图案。
我们用差值的平方和来表示”接近“:
(1)
当公式(1)的值越小时,白色图片转换出的画面内容与输入图片越相似。因此我们只要把白色图片中的像素点按照公式(1)的值越来越小的方向调整即可。
我们知道是白色图片中某个像素点x经过卷积运算后得到的结果,于是x与之间存在函数关系(x),当像素点x的值变动后,也会跟着变动。因此要想调整x使得公式(1)最小,我们只要将公式(1)对x求偏导即可:
(2)
其中,可以i通过对VGG网络进行梯度下降法来获取。根据公式(2)我们就知道了如何调整每个像素点 x 的值,使得白色图片转变为与输入图片内容相同的画风。现在内容有了,风格如何实现呢?
我们把抽取风格的绘画图片输入VGG网络中,在卷积层中已经包含了“风格”信息。要想对所谓的风格进行量化,我们需要计算对应卷积层的格莱姆矩阵。我们用 表示第 l 层卷积层对应的格莱姆矩阵,那么有: (3)
也就是说格莱姆矩阵第 i 行第 j 列个元素就是把卷积层对应的第 i 行和第 j 行做点乘后得到。公式(3)中对应的就是对输入图片“风格”的量化。与前面描述类似,我们要想把一张白色图片转换成给定图片风格相近的图片,就需要对下面的公式进行量化。
(4)
其中,是将白色图片输入到VGG网络后,将第L层卷积层转换后的格莱姆矩阵,我们选L个卷积层的格莱姆之和作为“风格”的量化:
(5)
于是要调整像素点的值使得公式(5)最小,其中对应每个卷积层权重,一般而言,权重会平均分配给每个卷积层。
对于像素点x,它的调整幅度为:
(6)
现在出现一个问题,对内容进行调整时,我们对像素点x进行更改;对风格进行调整时,我们又会对像素点进行更改,如何将两种更改统一起来呢?我们对两种更改赋予不同的权重:
(7)
如果 系数比较大,那么我们就使白色图片的改变偏向于内容;如果系数 比较大,那么我们就让图片的调整偏向于风格。于是像素点的调整只要把像素点x的值减去 即可,其中 是调整率,它跟我们前面提到过的学习率是一致的
接下来我们1启动代码加以理解。
代码理解
1.导入函数库
import tensorflow as tf
#import tensorflow.contrib.eager as tfe
#from tensorflow.python.keras.preprocessing import image as kp_image
from tensorflow.python.keras import models
from tensorflow.python.keras import losses
from tensorflow.python.keras import layers
from tensorflow.python.keras import backend as K
import IPython.display
#启动eager
#tf.enable_eager_execution()
#print("Enger execution:{}".format(tf.executing_eagerly()))
2.加载内容图片和风格图片
from PIL import Image
import matplotlib.pyplot as plt
import matplotlib as mpl
mpl.rcParams['figure.figsize'] = (10,10)
mpl.rcParams['axes.grid'] = False
import numpy as np
import time
import functools
content_path = "C:\\Users\\91144\\Pictures\\Screenshots\\屏幕截图 2024-09-23 214511.png"
style_path = "C:\\Users\\91144\\Pictures\\Screenshots\\屏幕截图 2024-09-26 184416.png"
# 加载显示图片
def load_img(path):
max_dim = 512 #设置图像的最大尺寸为512像素
img = Image.open(path) #打开图片
long = max(img.size) #计算图像最长边
scale = max_dim / long #计算缩放比例
if scale < 1:
# 使用 Image.Resampling.LANCZOS
img = img.resize((int(img.size[0] * scale), int(img.size[1] * scale)), Image.Resampling.LANCZOS) #使用LANCZOS算法缩放图像
img = np.array(img) #转为numpy数组
img = np.expand_dims(img, axis=0)#增加一个维度,使其符合深度学习模型输入的要求
return img
def show_img(img, title=None): #定义显示图像的函数
out = np.squeeze(img, axis=0)#从图像数组中移除批次维度
out = out.astype('uint8') #将图像数组的数据类型转为无符号整型,这是图像显示的标准格式
plt.imshow(out)
if title is not None:
plt.title(title)
plt.figure(figsize=(10,10)) #加载图像内容
content = load_img(content_path)
style = load_img(style_path)
plt.subplot(1, 2, 1)
show_img(content, 'Content Image')
plt.subplot(1, 2, 2)
show_img(style, 'Style Image')
plt.show()
TIPs:
上面有一个LANCZOS算法,它主要用于求解稀疏矩阵的特征值问题,我们这里用它缩放图像。
LANCZOS算法的原理
Lanczos 插值使用 Lanczos 核函数来计算插值后的像素值。Lanczos 核函数是一种低通滤波器,可以消除缩放过程中产生的混叠现象。
Lanczos 核函数定义如下:
其中,sinc(x) = sin(πx) / (πx),a 是核函数的宽度。
Lanczos 插值的过程如下:
- 确定插值点的位置。
- 以插值点为中心,在原图像中取一个窗口。
- 对窗口中的每个像素,使用 Lanczos 核函数计算其权重。
- 将窗口中所有像素的权重和插值值相乘,得到插值点的最终值。
Lanczos 插值公式:
Lanczos 插值的优点
- 与其他插值方法相比,Lanczos 插值可以产生更清晰、更平滑的图像。
- Lanczos 插值可以有效地抑制混叠现象,尤其是在图像缩小的情况下。
Lanczos 插值的缺点
- 与其他的插值方法相比,Lanczos 插值的计算量更大。
- Lanczos 插值可能会产生轻微的振铃效应,尤其是在图像放大边缘处
3.用vgg卷积网络抽取特征
import tensorflow as tf
from tensorflow.keras.applications import vgg19
from tensorflow.keras.preprocessing import image as kp_image
from tensorflow.keras.models import Model
def load_and_process_img(path):
# 加载图片
img = kp_image.load_img(path, target_size=(224, 224)) # 确保图片大小与 VGG19 输入一致
# 将图片转换为数组
img = kp_image.img_to_array(img)
# 扩展维度,因为 VGG19 需要批次维度
img = np.expand_dims(img, axis=0)
# 预处理图片
img = vgg19.preprocess_input(img)
return img
# 定义用于内容调整的卷积层
content_layers = ['block5_conv2'] # 使用存在的层名称
# 定义用于风格计算的卷积层
style_layers = ['block1_conv1',
'block2_conv1',
'block3_conv1',
'block4_conv1',
'block5_conv1']
num_content_layers = len(content_layers)
num_style_layers = len(style_layers)
# 加载 VGG19 网络,让它识别内容
def get_model():
# 我们不需要最后的全连接层
vgg = vgg19.VGG19(include_top=False, weights='imagenet')
# 获取用于计算风格的卷积层
style_outputs = [vgg.get_layer(name).output for name in style_layers]
# 获取用于计算内容的卷积层
content_outputs = [vgg.get_layer(name).output for name in content_layers]
model_outputs = style_outputs + content_outputs
return Model(vgg.input, model_outputs)
# 获取模型
model = get_model()
TIPs:
我们来解释一下里面的VGG卷积网络和block5_conv2,block1_conv2这些网络层的含义
在VGG网络层中,每个卷积块包含若干卷积层,后面跟着一个最大池化层,这些卷积块被编号为
block1
、block2
、block3
、block4
和 block5。
block5_conv2
:这是VGG网络中第五个卷积块的第二个卷积层,在VGG-19中,block5
包含三个卷积层(conv5_1
、conv5_2
、conv5_3
),其中conv5_2
对应于block5_conv2。
这些层的名称通常用于风格迁移任务中,用于提取图像的内容特征和风格特征,block5_conv2
由于其在网络的较深位置,能够捕捉到图像的高级语义信息,适合作为内容特征层。而block1_conv1
等较浅层的卷积层则能捕捉到图像的纹理和颜色信息,适合作为风格特征层。
4.计算内容对应卷积层的差值,以便后面调整图片的像素点
def get_content_loss(base_content,target):
return tf.reduce_mean(tf.square(base_content - target))
5.计算风格差异
#计算风格差异
def gram_matrix(input_tensor): #格拉姆矩阵
channels = int(input_tensor.shape[-1])
'''
这里的-1相当于把卷积层对应的平面拉成一条直线
'''
a = tf.reshape(input_tensor,[-1,channels])
n = tf.shape(a)[0]
gram = tf.matmul(a,a,transpose_a = True) #将第一个张量a进行转置,然后与第二个张量a进行矩阵乘法
return gram/tf.cast(n,tf.float32)
#计算风格差异
def get_style_loss(base_style,gram_target):
gram_style = gram_matrix(base_style)
return tf.reduce_mean(tf.square(gram_style - gram_target))
TIPs:
这里有一个格拉姆矩阵,图像处理和风格迁移的上下文中,格拉姆矩阵用于捕捉和表示图像的风格特征,特别是纹理和颜色分布。
定义
对于一个给定的矩阵 A,其格拉姆矩阵 G 定义为 A 与其自身的内积。如果 A 是一个 m×n 的矩阵,那么格拉姆矩阵 G 是一个 n×n 的矩阵,计算方式如下:
其中 A^T 是 A 的转置。
图像风格迁移中的应用
在图像风格迁移中,格拉姆矩阵用于表示风格图像的特征图(feature maps)。特征图是深度卷积神经网络(如VGG)中某一层的输出,它捕捉了图像的局部特征。格拉姆矩阵则将这些局部特征转换为全局风格特征,因为它包含了特征图之间的相关性信息。
计算步骤
-
提取特征图:
- 从风格图像中提取特征图。这通常是通过将风格图像输入到预训练的深度学习模型(如VGG)中,并选择特定层的输出来完成的。
-
计算格拉姆矩阵:
- 对于每个特征图,计算其格拉姆矩阵。如果特征图的形状是 [1,h,w,c][1,h,w,c](其中 hh 是高度,ww 是宽度,cc 是通道数),那么格拉姆矩阵的形状将是 [c,c][c,c]。
-
风格损失:
- 在风格迁移的损失函数中,格拉姆矩阵用于计算生成图像的风格特征与目标风格图像之间的差异。这种差异通常通过格拉姆矩阵的元素之间的差异来衡量。
-
计算差异:
1.使用均方误差衡量两矩阵的差异:
均方误差是衡量两个矩阵之间差异的一种简单方法,定义为两个矩阵元素差的平方的平均值:
2.交叉相关:在风格迁移中,我们通常使用一种特殊的交叉相关来衡量风格差异,这实际上是格拉姆矩阵的差异。如果 GAGA 和 GBGB 分别是两个特征图 AA 和 BB 的格拉姆矩阵,那么它们的差异可以表示为:
其中 NN 是特征图的通道数,MM 是格拉姆矩阵的维度(通常是特征图通道数)。
6.把内容图片和风格图片输入到VGG19卷积网络,获得相应卷积层的输出结果
def get_feature_representations(model,content_path,style_path):
#把图片内容和风格图片输入到vgg网络,然后获取相应的卷积计算结果
content_img = load_and_process_img(content_path)
style_img = load_and_process_img(style_path)
style_outputs = model(style_img)
content_outputs = model(content_img)
#抽取风格图片对应的卷积层输出
style_features = [style_layer[0] for style_layer in style_outputs[:num_style_layers]]
content_features = [content_layer[0] for content_layer in content_outputs[num_style_layers:]]
return style_features,content_features
7.计算公式(2),和公式(6),最后根据公式(7)来调整白色图片的像素点
def computer_loss(model, loss_weights, init_image, gram_style_features, content_features):
style_weight, content_weight = loss_weights
# 计算白色图片对应卷积层的输出
model_outputs = model(init_image)
# 获得白色图片对应风格卷积层的计算结果
style_output_features = model_outputs[:num_style_layers]
content_output_features = model_outputs[num_style_layers:]
style_score = 0
content_score = 0
# 计算风格对应卷积层权重
weight_per_style_layer = 1.0 / float(num_style_layers)
for target_style, comb_style in zip(gram_style_features, style_output_features):
style_score += weight_per_style_layer * get_style_loss(comb_style[0], target_style)
# 计算内容差值
weight_per_content_layer = 1.0 / float(num_content_layers)
#将两个列表组合在一起,使得每次迭代都能同时访问目标内容特征(target_content)和生成图像的内容特征(comb_content)
for target_content, comb_content in zip(content_features, content_output_features):
content_score += weight_per_content_layer * get_content_loss(comb_content[0], target_content)
# 根据公式7计算像素点的调节
style_score *= style_weight #调整风格得分
content_score *= content_weight #调整内容得分
loss = style_score + content_score #计算总损失
return loss, style_score, content_score
8.根据公式(2)和公式(6)我们还得计算像素点相对于卷积层输出结果的偏导,把差值和偏导结合在一起才得到像素点的调整幅度
#把差值和偏导结合在一起才得到像素点的调整幅度
def compute_grads(cfg):
with tf.GradientTape() as tape:
all_loss = computer_loss(**cfg)
total_loss = all_loss[0]
#根据差值反向对像素点求偏导
gradient = tape.gradient(total_loss,cfg['init_image'])#计算梯度
return gradient,all_loss
9.最后我们把所有计算流程连接起来,不断调整白色图片像素点的值,让它慢慢变成具体有相应内容和风格的新的图片
import numpy as np
import tensorflow as tf
from tensorflow import keras
from PIL import Image
import IPython
import time
#用于将处理过的图像(经过 VGG 网络预处理的图像)转换回可显示的图像格式。
def deprocess_img(processed_img):
x = processed_img.copy()
if len(x.shape) == 4:
x = np.squeeze(x, 0) #去掉批次维度
assert len(x.shape) == 3
x[:, :, 0] += 103.939 #将vgg网络均值加回到每个颜色通道上,以撤销预处理步骤
x[:, :, 1] += 116.779
x[:, :, 2] += 123.68
x = x[:, :, ::-1] #将通道顺序从RBGR转换到RGB
x = np.clip(x, 0, 255).astype('uint8')#将像素点限制在0-255,并转化为无符号整型,这是图像显示的标准格式
return x
#实现风格迁移的主要逻辑。
def run_style_transfer(content_path, style_path,
num_iterations=1000,
content_weight=1e3,
style_weight=1e-2):
model = get_model() # 假设这个函数返回一个预训练的VGG模型
for layer in model.layers:
layer.trainable = False
style_features, content_features = get_feature_representations(model, content_path, style_path)
gram_style_features = [gram_matrix(style_feature) for style_feature in style_features]
init_image = load_and_process_img(content_path)
init_image = tf.Variable(init_image, dtype=tf.float32)
opt = tf.keras.optimizers.Adam(learning_rate=5, beta_1=0.99, epsilon=1e-1)#优化器
iter_count = 1
best_loss, best_img = float('inf'), None
loss_weights = (style_weight, content_weight)
#配置字典
cfg = {
'model': model,
'loss_weights': loss_weights,
'init_image': init_image,
'gram_style_features': gram_style_features,
'content_features': content_features
}
num_rows = 2
num_cols = 5
display_interval = num_iterations / (num_rows * num_cols)
start_time = time.time()
global_start = time.time()
norm_means = np.array([103.939, 116.779, 123.68])
min_vals = -norm_means
max_vals = 255 - norm_means
imgs = []
#在迭代过程中,使用 compute_grads 函数(未提供)计算损失相对于初始图像的梯度,并使用优化器更新图像
for i in range(num_iterations):
grads, all_loss = compute_grads(cfg) #计算梯度和损失
loss, style_score, content_score = all_loss
opt.apply_gradients([(grads, init_image)]) #使用优化器
clipped = tf.clip_by_value(init_image, min_vals, max_vals)#使用 tf.clip_by_value 将图像的像素值限制在合法范围内,防止梯度更新导致的像素值超出 [0, 255] 的范围。
init_image.assign(clipped) #将裁剪后的图像值赋回 init_image
end_time = time.time()
#如果当前损失小于最佳损失,则更新最佳损失和最佳图像。
if loss < best_loss:
best_loss = loss
best_img = deprocess_img(init_image.numpy())
if i % display_interval == 0:
start_time = time.time()
plot_img = init_image.numpy()
plot_img = deprocess_img(plot_img)
imgs.append(plot_img)
IPython.display.clear_output(wait=True) #清除之前的输出,以便显示新的图像。
IPython.display.display_png(Image.fromarray(plot_img)) #显示图像
print('Iteration: {}'.format(i))
print('Total loss: {:.4e}, style loss: {:.4e}, content loss: {:.4e}, time: {: .4f}s'.format(loss, style_score, content_score, time.time() - start_time))
print('Total time: {:.4f}s'.format(time.time() - global_start))
return best_img, best_loss
10.最后跑出来
run_style_transfer(content_path,style_path,num_iterations = 1000)