神經網路風格轉換
最近搞的很火紅的圖片修改技術,是2015年夏天由 Leno Gatys 等人發表的神經風格轉換技術(Neural style transfer),不用幾分鐘就可以畫出大師級的繪畫,而且還聲稱這些藝術大師快沒工作了。
藝術風格轉換的主要目的是將一張參考圖片(就是一張名畫)的風格(style),套用到另一張普通的原始圖片上,最後生成的圖片既能保留原始圖片的大致內容,又能展現出名畫的風格。這裡得風格,狹義的定義,是指名畫中的的紋理、色彩、視覺模式等等。比如紋理有個特點是和所在位置無關,基於這個特點,只要是和位置無關的統計信息,都可以試著來表示紋理的特徵。而原始圖片的內容 (content),則指的是圖片的宏觀構造。
本人是看不出這到底有什麼價值啦,也實在看不出底下的風格照到底是在有名什麼意思。不過本人猜想能讓專家看不出這是電腦繪製,而且會被誤會成是人工繪製的,那就是有價值的 “真品” 吧。
底下是本範例產生的結果
原始圖 :

風格照 :

合成圖 :

硬体限制
顯卡內存如果是 12G 的話,只能把圖片縮小到 1500 *1125 的解析度,超出這個範圍就會出現 OOM 的錯誤。
相關演算法
AI 繪圖最重要的演算法是格拉姆矩陣(Gram),所以需花點時間了解底下的數學計算。
向量內積
二個向量相對應的位置相乘後求總和。以數學公式定義如下
$(a=\begin{bmatrix}a_{1}, a_{2}, a_{3}, …., a_{n}\end{bmatrix})$
$(b=\begin{bmatrix}b_{1}, b_{2}, b_{3}, …., b_{n}\end{bmatrix})$
$(a\cdot b = a_{1}b_{1} + a_{2}b_{2} + a_{3}b_{3} + …. +a_{n}b_{n})$
向量內積的作用是在判斷二向量之間的夾角和方向關係。
$(a\cdot b>0)$ : 方向大致相同,夾角在$(0^{\circ} \sim 90^{\circ})$之間
$(a\cdot b=0)$ : 正交,相互垂直
$(a\cdot b<0)$ : 方向基本相反,夾角在$(90^{\circ} \sim 180^{\circ})$之間
格拉姆矩陣 Gram Matrix
格拉姆矩陣定義如下
$(G(x_{1},x_{2},x_{2},…,x_{n}) = \begin{bmatrix}
(x_{1}x_{1})&(x_{1}x_{2})&…&(x_{1}x_{n})\\
(x_{2}x_{1})&(x_{2}x_{2})&…&(x_{2}x_{n})\\
… & … & … & \\
(x_{n}x_{1})&(x_{n}x_{2})&…&(x_{n}x_{n})\\
\end{bmatrix})$
底下的代碼,使用 np.outer(a,a) 即可算出格拉姆的結果
import numpy as np a=np.array([1,2,3,4,5]) print(np.outer(a,a)) 結果: [[ 1 2 3 4 5] [ 2 4 6 8 10] [ 3 6 9 12 15] [ 4 8 12 16 20] [ 5 10 15 20 25]]
上述先取得 “1” , 然後跟 [1,2,3,4,5]相乘產生第一列的結果,再取 “2” , 然後跟 [1,2,3,4,5] 相乘而產生第二列的結果,依此類推。
二維格拉姆陣列
二維陣列,比如 (3,5) 的 a 陣列,需先轉置成 (5, 3) 的 b 二維陣列
接著把 b 陣列扁平化成 [1,6, 11, 2, 7, 12, 3, 8, 13, 4, 9, 14, 5, 10, 15]
取 a 的第一個值 “1” 跟 b 相乘,形成第一列
取 a 的第二個值 “2” 跟 b 相乘,形成第二列
其餘以此類推
最後的結果,是 (3*5,3*5) = (15,15) 的二維陣列
import numpy as np a = np.array([[1,2,3,4,5], [6, 7, 8, 9, 10], [11,12,13,14,15]]) #b = np.transpose(a)
b = a.T print("a 陣列如下") print(a) print("b 陣列如下") print(b) print("格拉姆陣列如下") print(np.outer(a,b)) 結果 : a 陣列如下 [[ 1 2 3 4 5] [ 6 7 8 9 10] [11 12 13 14 15]] b 陣列如下 [[ 1 6 11] [ 2 7 12] [ 3 8 13] [ 4 9 14] [ 5 10 15]] 格拉姆陣列如下 [[ 1 6 11 2 7 12 3 8 13 4 9 14 5 10 15] [ 2 12 22 4 14 24 6 16 26 8 18 28 10 20 30] [ 3 18 33 6 21 36 9 24 39 12 27 42 15 30 45] [ 4 24 44 8 28 48 12 32 52 16 36 56 20 40 60] [ 5 30 55 10 35 60 15 40 65 20 45 70 25 50 75] [ 6 36 66 12 42 72 18 48 78 24 54 84 30 60 90] [ 7 42 77 14 49 84 21 56 91 28 63 98 35 70 105] [ 8 48 88 16 56 96 24 64 104 32 72 112 40 80 120] [ 9 54 99 18 63 108 27 72 117 36 81 126 45 90 135] [ 10 60 110 20 70 120 30 80 130 40 90 140 50 100 150] [ 11 66 121 22 77 132 33 88 143 44 99 154 55 110 165] [ 12 72 132 24 84 144 36 96 156 48 108 168 60 120 180] [ 13 78 143 26 91 156 39 104 169 52 117 182 65 130 195] [ 14 84 154 28 98 168 42 112 182 56 126 196 70 140 210] [ 15 90 165 30 105 180 45 120 195 60 135 210 75 150 225]]
三維格拉姆
歹勢喔,沒有三維格接姆,最多只有二維。所以必需將多維陣列轉成二維陣列,再計算格拉姆。
變異數(Variance)與標準差(standard deviation)
由以前的說明,標準差是將 (所有數-平均值)平方總和/總數,再開根號,數學公式如下。
$(\sqrt{\frac{1}{n}\sum (x_{i}-mean)^{2}})$
那麼變異數又是什麼? 就是上述計算時,不開根號。
$(\frac{1}{n}\sum (x_{i}-mean)^{2})$
也就是說,標準差就是變異數的開根號。由如下代碼可以驗証
import numpy as np
np.random.seed(1)
a=np.random.randint(1,100,10)
mean=a.mean()
sum=0
for i in a:
sum+=(i-mean)**2
print("手動變異數",sum/10)
print("手動標準差",(sum/10)**0.5)
print("np 變異數",np.var(a))
print("np 標準差",np.std(a))
結果:
手動變異數 933.2
手動標準差 30.548322376196047
np 變異數 933.2
np 標準差 30.548322376196047
圖片應用
上述講了一大堆數學的計算方法,實在是搞不懂要幹嘛,所以底下用圖片的方式來說明其運用。
讀取圖片後,需先把圖片縮小到 800*600 像素左右,如果圖片過大,產生的格拉姆陣列會超級大的,會產生記憶体不足的問題。
由 cv2 讀取的格式,是 [h, w, c] 的三維陣列,h是高度,w是寬度,c 是 channel (BGR,固定為 3)。所以必需使用 np.transport(img, (2, 0, 1)) 把 chanel 拉到最前面的維度,變成 [c, h, w] 的三維陣列,然後再用 np.reshape(3, -1) 變成 [3, h*w] 的二維陣列。經此轉換後,會變成第一列為每點藍色值偏平化,第二列為綠色值偏平化,第三列為紅色值偏平化。
[[藍1, 藍2, 藍3, 藍4, ……藍n],
[綠1, 綠2, 綠3, 綠4, ……綠n],
[紅1, 紅2, 紅3, 紅4, ……紅n]]
最後產生的格拉姆二維陣列為 [3*h*w, 3*h*w]。
import numpy as np
import cv2
base_path='./images/input6.jpg'
img = cv2.imdecode(np.fromfile(base_path, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
original_height,original_width=img.shape[:2]
height=100
width= round(original_width * height / original_height)
print(f'圖片 高*寬: {height}*{width}')
img = cv2.resize(img, (width, height))
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
#將[h, w, c] 轉成 [c, h, w]
img=np.transpose(img,(2,0,1))
a=np.reshape(img, (3, -1)).astype(np.int32)
b=np.transpose(a, (1, 0)).astype(np.int32)
c=np.outer(a,b)
print("格拉姆 shape : ",c.shape)
print(c)
結果 :
圖片 高*寬: 100*133
格拉姆 shape : (39900, 39900)
[[33124 36218 39676 ... 32032 32214 32942]
[32760 35820 39240 ... 31680 31860 32580]
[33670 36815 40330 ... 32560 32745 33485]
...
[33670 36815 40330 ... 32560 32745 33485]
[29848 32636 35752 ... 28864 29028 29684]
[32942 36019 39458 ... 31856 32037 32761]]
格拉姆是在取得每個特徵跟其它特徵之間的關聯性。這個關聯性可以想成是在取得某個空間要使用的紋理,這樣相同的紋理就會重複出在不同的位置。
風格轉換步驟
底下是風格轉換的步驟。一開始合成圖跟原始圖是一模一樣的。

公式總覽
底下的觀念及代碼非常的複雜,所以只要記得 Gatys 等人所定義的方式即可。等這些定義記熟了之後,再慢慢的去探討相關的理論。
- 一開始,合成圖與原始內容圖一模一樣。
- 計算原始內容與合成圖 “block5_conv2” 特徵損失 + 風格圖及合成圖 “block1_conv1”, “block2_conv1”, “block3_conv1”, “block4_conv1”, “block5_conv1” 5 層特徵的格拉姆損失。
- 計算上述損失函數的梯度下降。
- 解開合成圖查看結果。
- 重複 2~4 步驟 4000次。
上述第 4 步驟點,並不是每一次都要解開圖片觀看,否則會很慢。所以可以每 100 個步驟再解開一次。
VGG19 Block
vgg19 模型總共有 5個block, 每個 block由2~4個捲積層組成。較低階的block(比如 block1) 所取得的特徵數較少,比如 block1只有64個特徵,通常是取得顏色,邊緣等特徵。較高階的block, 比如block5 加深了特徵的截取,所以特徵數較多,比如block5可達512種特徵,這通常代表著元素的組成單位,比如點線弧等特徵。
model = applications.vgg19.VGG19(weights="imagenet", include_top=False)
outputs_dict = dict([(layer.name, layer.output) for layer in model.layers])
for i, layer in enumerate(model.layers):
print(i+1, layer.name)
結果:
1 input_1
2 block1_conv1
3 block1_conv2
4 block1_pool
5 block2_conv1
6 block2_conv2
7 block2_pool
8 block3_conv1
9 block3_conv2
10 block3_conv3
11 block3_conv4
12 block3_pool
13 block4_conv1
14 block4_conv2
15 block4_conv3
16 block4_conv4
17 block4_pool
18 block5_conv1
19 block5_conv2
20 block5_conv3
21 block5_conv4
22 block5_pool
原理
利用損失函數的組合產生合成圖,整体的函數為
$(Loss(C)= \sum (B_{i}^{b5c2}-C_{i}^{b5c2})^2 +)$
$(\sum (G(S_{i}^{b1c1}-C_{i}^{b1c1})^2+\sum (G(S_{i}^{b2c1}-C_{i}^{b2c1})^2+ )$
$(\sum (G(S_{i}^{b3c1}-C_{i}^{b3c1})^2+\sum (G(S_{i}^{b4c1}-C_{i}^{b4c1})^2+ )$
$(\sum (G(S_{i}^{b5c1}-C_{i}^{b5c1})^2)$
vgg19 模型總共
原始圖與合成圖損失
合成圖一開始跟原始圖一樣。每一次迭代,都要盡量與原始圖一樣,所以只需高階 block5_conv2 捲積層取得最為詳細的特徵。所以只要計算 block5_conv2 的損失函數即可。損失函數為 (原始內容 – 合成圖) 的平方總和。最後因為損失值過大,所以要乘上一個權重,目前我們定為content_weight = 2.5e-8
loss = loss + content_weight * tf.reduce_sum(tf.square(combination_feature - base_feature))
風格圖與合成圖損失
風格圖與合成圖的組合,需將 block1~5 的 conv1 5層作損失函數加總,也就是要提取風格照的邊緣,紋理等所有的特徵。每層加總前,需將風格圖與合成照改成格拉姆矩陣 s 及 c,然後將 (s-c) 平方,再除以 (4*(channels**2)*(size**2)) 縮小損失,再作加總。最後將這5層的損失相加。
使用格拉姆矩陣的目地,是在取得各個特徵之間的關聯性,這此關聯性代表著該空間比例下所辨識出的紋理外觀,這個風格照的特定紋理就會重複出現在合成照的不同地方。如果不使用格拉姆矩陣的話,就只是把整張風格照模糊化然後套到合成照中。
def style_loss(style_feature, combination_feature):
s = gram_matrix(style_feature)
c = gram_matrix(combination_feature)
channels = 3
size = height * width
#(4.0*(channels ** 2) * (size **2)) 為官網建議,可以自已調整看看
return tf.reduce_sum(tf.square(s - c)) / (4.0 * (channels ** 2) * (size ** 2))
for layer_name in style_layer_names:
style_feature = style_features[layer_name]
combination_feature=combination_features[layer_name]
loss = style_loss(
style_feature, combination_feature)
total_loss += (style_weight / len(style_layer_names)) * loss
preprocess_input
keras 的 preprocess_input 會先把 RGB (224,224,3) 轉成 (1, 224,224,3) 維,然後將每個點的值減去平均值,讓每個值分佈在 0 的左右二邊(有正有負),以適用於 ResNet50 的要求。
完整代碼
#pip install tensorflow opencv-python matplotlib
import os
os.environ['TF_CPP_MIN_LOG_LEVEL']='2'
import shutil
import keras
import numpy as np
from keras.src.applications.vgg19 import VGG19, preprocess_input
from keras.src.optimizers import SGD
from MahalSdk.cv import cv
import cv2
import tensorflow as tf
import pylab as plt
def deprocess_image(img):
# img 為(1, 600, 800, 3) 4維度, 需改成 (600,800,3) 3維度
img=np.reshape(img, (height, width, 3))[:,:,::-1].copy()
#底下是將亮度調亮
img[:, :, 0] += 123.68
img[:, :, 1] += 116.779
img[:, :, 2] += 103.939
#將小於 0 的值變為 0,超出 255 的值,改為255
img = np.clip(img, 0, 255).astype(np.uint8)
return img
#加入全變異損失,會有較小的變化,也可以不加
def total_variation_loss(x):
a = tf.square(
x[:, : height - 1, : width - 1, :] - x[:, 1:, : width - 1, :]
)
b = tf.square(
x[:, : height - 1, : width - 1, :] - x[:, : height - 1, 1:, :]
)
return tf.reduce_sum(tf.pow(a + b, 1.25))
def gram_matrix(x):
x=tf.reshape(x, (x.shape[1], x.shape[2], x.shape[3]))
x=tf.transpose(x, (2, 0, 1))
features=tf.reshape(x, (tf.shape(x)[0], -1))
gram=tf.matmul(features, tf.transpose(features))
return gram
def content_loss(base_feature, combination_feature):
return tf.reduce_sum(
tf.square(base_feature - combination_feature)
) * content_weight
def style_loss(style_feature, combination_feature):
s=gram_matrix(style_feature)
c=gram_matrix(combination_feature)
channels=3
size=width*height
# (4.0*(channels ** 2) * (size **2)) 為官網建議,可以自已調整看看
return tf.reduce_sum(tf.square(s-c))/(4*(channels**2)*(size**2)) * style_weight
#@tf.function
def compute_grads(combination_img, base_img, style_img):
base_features = model(base_img)
base_feature = base_features[content_layer_name]
style_featuers=model(style_img)
with tf.GradientTape() as tape:
#一定要寫在 with 區塊中
total_loss = tf.zeros(shape=())
combination_features = model(combination_img)
combination_feature=combination_features[content_layer_name]
total_loss += content_loss(base_feature, combination_feature)
for layer in style_layer_names:
style_feature=style_featuers[layer]
combination_feaure=combination_features[layer]
#底下需先轉成格拉姆再計算 loss
total_loss+=style_loss(style_feature, combination_feaure)
grads=tape.gradient(total_loss, combination_img)
return grads
model=VGG19(weights='imagenet', include_top=False)
outputs=dict([(layer.name, layer.output) for layer in model.layers])
model=keras.Model(inputs=model.input, outputs=outputs)
content_layer_name='block5_conv2'
style_layer_names=[
'block1_conv1',
'block2_conv1',
'block3_conv1',
'block4_conv1',
'block5_conv1',
]
total_variation_weight=1e-6
style_weight=1e-6/len(style_layer_names)
content_weight=2.5e-8
base_img=cv.resize(
cv.read("draw_1.jpg")[:,:,::-1].copy(),
width=1500
)
height, width, _ = base_img.shape
print(height, width)
base_img=preprocess_input(np.expand_dims(base_img, axis=0))
style_img=cv2.resize(
cv.read("starry_night.jpg")[:,:,::-1].copy(),
(width, height),
interpolation=cv2.INTER_LINEAR)
style_img=preprocess_input(np.expand_dims(style_img, axis=0))
combination_img=tf.Variable(base_img)
epochs=4001
optimizer=SGD(
keras.optimizers.schedules.ExponentialDecay(
initial_learning_rate=100.0,
decay_steps=100,
decay_rate=0.96
)
)
output_path="./output"
if os.path.exists(output_path):
shutil.rmtree(output_path)
os.mkdir(output_path)
fig, ax=plt.subplots()
for i in range(epochs):
grads=compute_grads(
combination_img,base_img, style_img
)
optimizer.apply_gradients([(grads, combination_img)])
img=deprocess_image(combination_img.numpy())
if i % 100==0:
file=os.path.join(output_path, f'combination_epoch{i}.jpg')
pil = Image.fromarray(img)
pil.save(file)
#keras.utils.save_img(file, img)
ax.clear()
ax.axis("off")
ax.imshow(img)
plt.pause(0.01)
print(f'epoch : {i}')
plt.show()
Epochs
上述如果將 Epochs 改到 10000,結果跟 4000 差不多,風格紋理並不會覆蓋掉原始狗狗的圖片,如下圖所示

colab版本
在 colab 裏執行,無法使用 plt 持續更新,需使用 cv2_imshow 才能更新圖片,完整代碼如下
#pip install tensorflow opencv-python matplotlib
#先將 draw_1.jpg 與 starry_night.jpg copy 到雲端硬碟的 pictures 資料夾
from google.colab import drive
from google.colab.patches import cv2_imshow
import os.path
import shutil
import keras
import numpy as np
drive.mount('/data')
pic_path="/data/MyDrive/pictures"
sdk_path="/data/MyDrive/sdk/"
import sys
sys.path.append(sdk_path)
from cv import cv
from keras.src.applications.vgg19 import VGG19, preprocess_input
from keras.src.optimizers import SGD
import cv2
import tensorflow as tf
import pylab as plt
def deprocess_image(img):
# img 為(1, 600, 800, 3) 4維度, 需改成 (600,800,3) 3維度
#img=img.reshape((height, width, 3))[:,:,::-1].copy()
img=np.reshape(img, (height, width, 3))[:,:,::-1].copy()
#底下是將亮度調亮
img[:, :, 0] += 123.68
img[:, :, 1] += 116.779
img[:, :, 2] += 103.939
#將小於 0 的值變為 0,超出 255 的值,改為255
img = np.clip(img, 0, 255).astype(np.uint8)
return img
#加入全變異損失,會有較小的變化,也可以不加
def total_variation_loss(x):
a = tf.square(
x[:, : height - 1, : width - 1, :] - x[:, 1:, : width - 1, :]
)
b = tf.square(
x[:, : height - 1, : width - 1, :] - x[:, : height - 1, 1:, :]
)
return tf.reduce_sum(tf.pow(a + b, 1.25))
def gram_matrix(x):
x=tf.reshape(x, (x.shape[1], x.shape[2], x.shape[3]))
x=tf.transpose(x, (2, 0, 1))
features=tf.reshape(x, (tf.shape(x)[0], -1))
gram=tf.matmul(features, tf.transpose(features))
return gram
def content_loss(base_feature, combination_feature):
return tf.reduce_sum(
tf.square(base_feature - combination_feature)
) * content_weight
def style_loss(style_feature, combination_feature):
s=gram_matrix(style_feature)
c=gram_matrix(combination_feature)
channels=3
size=width*height
# (4.0*(channels ** 2) * (size **2)) 為官網建議,可以自已調整看看
return tf.reduce_sum(tf.square(s-c))/(4*(channels**2)*(size**2)) * style_weight
#@tf.function
def compute_grads(combination_img, base_img, style_img):
base_features = model(base_img)
base_feature = base_features[content_layer_name]
style_featuers=model(style_img)
with tf.GradientTape() as tape:
#一定要寫在 with 區塊中
total_loss = tf.zeros(shape=())
combination_features = model(combination_img)
combination_feature=combination_features[content_layer_name]
total_loss += content_loss(base_feature, combination_feature)
for layer in style_layer_names:
style_feature=style_featuers[layer]
combination_feaure=combination_features[layer]
#底下需先轉成格拉姆再計算 loss
total_loss+=style_loss(style_feature, combination_feaure)
grads=tape.gradient(total_loss, combination_img)
return grads
model=VGG19(weights='imagenet', include_top=False)
outputs=dict([(layer.name, layer.output) for layer in model.layers])
model=keras.Model(inputs=model.input, outputs=outputs)
content_layer_name='block5_conv2'
style_layer_names=[
'block1_conv1',
'block2_conv1',
'block3_conv1',
'block4_conv1',
'block5_conv1',
]
total_variation_weight=1e-6
style_weight=1e-6/len(style_layer_names)
content_weight=2.5e-8
base_img=cv.resize(
cv.read(os.path.join(pic_path,"draw_1.jpg"))[:,:,::-1].copy(),
width=1500
)
height, width, _ = base_img.shape
print(height, width)
base_img=preprocess_input(np.expand_dims(base_img, axis=0))
style_img=cv2.resize(
cv.read(os.path.join(pic_path,"starry_night.jpg"))[:,:,::-1].copy(),
(width, height),
interpolation=cv2.INTER_LINEAR)
style_img=preprocess_input(np.expand_dims(style_img, axis=0))
combination_img=tf.Variable(base_img)
epochs=4001
optimizer=SGD(
keras.optimizers.schedules.ExponentialDecay(
initial_learning_rate=100.0,
decay_steps=100,
decay_rate=0.96
)
)
output_path="./output"
if os.path.exists(os.path.join(pic_path,output_path)):
shutil.rmtree(os.path.join(pic_path,output_path))
os.mkdir(os.path.join(pic_path,output_path))
#fig, ax=plt.subplots()
for i in range(epochs):
grads=compute_grads(
combination_img,
base_img,
style_img
)
optimizer.apply_gradients([(grads, combination_img)])
img=deprocess_image(combination_img)
if i %100==0:
file=os.path.join(pic_path, output_path, f'combination_epoch{i}.jpg')
pil = Image.fromarray(img)
pil.save(file)
keras.utils.save_img(file,img)
cv2_imshow(cv2.cvtColor(img, cv2.COLOR_RGB2BGR))
cv2.waitKey(1)#不等待的話,無法顯示圖片
print(f"epoch : {i}")
參考
https://edge.aif.tw/booklist-deep-learning-with-python/
https://www.cnblogs.com/yifanrensheng/p/12547660.html
