上一篇談到了 GAN,今天就來介紹一下我在 Stanford CS231n 做的 GAN project —— Cross-Lingual Font Style Transfer,跨語言字型風格轉換。一開始選題覺得是滿多人做過的題目,但實作時遇到很多有趣的問題,研究解法的過程還挺有成就感的,算是做得滿有心得的 project。
那就透過這個 project,更深入了解一下 GAN 的應用吧!
程式碼、report、poster 都在 GitHub: pyliaorachel/cross-lingual-font-style-transfer。
字型設計是相當耗時耗力的過程。英文這種只有 26 個字母的還好一些,中文 5 萬個字要設計一整套簡直是勞民傷財。
這個 project 就是要透過 style transfer,將現有某個的語言的字型風格套用在另一個語言的字體上。我們以英文字型套用在中文字上作為例,研究三種不同 model 在做字型風格轉換時的效果。
我們嘗試三種 model 來做 style transfer:neural style transfer [1]、image transformation network [2]、以及 GAN 的變形 CycleGAN [3]。篇幅主要放在 CycleGAN 上,前兩者會簡單帶過。
一個 style transfer model 通常需要兩個 input:content image 來指定內容,以及 style image 來指定風格。而 output 就是兩者結合的一張 image。
Neural style transfer 的架構簡單來說,就是在 minimize output image 和 input content image 之間 content 的差距,同時 minimize output image 和 input style image 之間 style 的差距。所以核心是怎麼量化一張圖片的 content 和 style,以及定義差距 loss 來做訓練。
跟一般 model 不同的是,一般 model 我們藉由 training data 訓練 parameters,然後拿訓練好的 model 產生 output。但在 neural style transfer 中,我們會直接 initialize 最後的 output image,拿他和我們要的 content 和 style 比較,計算差距後直接調整 output image。也就是說其實沒有 model 的概念,而比較像是一種 algorithm。
架構如下:
—— Neural style transfer 架構。[1]
CNN 對 content image 和 style image 抽取特徵,中間一層層抽取到的 feature 正是我們想要的 content 和 style feature。拿這些 feature 跟 output image 在各層抽取到的 feature 比較差距,就能 backpropagate 回 output image 做調整了。
當然實際上省略很多細節,有興趣了解可以參考我的 report 或原始 paper [1]。
Neural style transfer 有個明顯的壞處,就是因為沒有 model 概念,每次想 output 一張圖,就得重頭開始捏陶土,非常不划算。
有鑑於此,image transformation network 在整個 neural style transfer 開始前放了一個 neural network,接收 input 產生 output 之後,再用這個 output image 開始做 neural style transfer。
但訓練時不只把 loss backpropagate 回 output image,更往前 backpropagate 回整個 network 做訓練。如此一來,經過幾組 style image 的訓練,這個 network 就可以直接用來接收 input 產生 styled image 了。
—— Image transformation network 架構。[2]
CycleGAN 則是完全不同的概念。他跟 GAN 一樣,定義了 generator G 來從 input 產生 styled image,以及 discriminator D 來判斷 styled image 是不是掌握了我們要的風格。
但單純這麼做的話,會產生一個問題:discriminator 不知道 output image 和 input image 在內容上是不是相關。
因此在 CycleGAN 中,我們定義兩組 GAN:第一組 generator G 負責從 input x 產生 output ,並由 discriminator 判斷是不是符合 domain Y 的風格;另一組 generator F 負責從 y 重建 ,並由 discriminator 判斷是不是符合 domain X。同時兩邊會要求 cycle-consistency loss:,。
架構大致如下:
—— CycleGAN 架構。[3]
通過這樣的架構訓練,不只能把一張 content image 轉換到 style domain,還能確保 content 還在,否則的話沒辦法通過 F 轉換回原本的 domain。
接下來我們會把 model 訓練在下面這組字型上:
上面中文字是 content image,英文字是 style image。我們要把粉筆風格轉換到中文字上。
第一個版本中,我們定義好 CycleGAN 開始訓練。但結果發現生成的結果,在 image 周圍常常都變成白色,如下圖:
—— 左邊為 content image,右邊為 styled image。
研究以後,發現原因在於英文字和中文字集中位置的差異。Styled image 中的英文字比較集中在中間部位,而 content image 中的中文字會塞滿整張圖。因此我們的 model 誤以為 domain Y 中,周圍沒有被填滿也是風格裡的一部分。
好吧,不算你錯,只是我們沒教好。那要怎麼改進呢?
我們想到了 image cropping 這個方法:把英文字體隨意剪取中間一塊一塊當作 style image。如此一來能移除字體中的 global style,留下我們要的 local font style。
成果有了顯著的改善:
可以看到字體現在更完整了!
解決了一個問題,又發現了另一個問題:怎麼 train 久一點,整張圖都不見了?
—— 不要懷疑,右邊有你看不見的東西。
我們不太確定是不是 model 壞掉了,但確認一下把右邊這張白色的圖從 domain Y 返回 domain X 後,確實能夠成功 reconstruct 原本的 content image。也就是說,model 學到了很深奧的東西,能夠保有原本的 content 資訊,但是沒辦法學到黑白色的資訊。
為了引導 model 走向正確的道路,我們從 neural style transfer 中借用了 content loss 的概念。也就是我們額外在 model 中加了一個 content loss 來確保 G 和 F 的前幾層在 content 上是接近的。注意到我們不能直接讓 output styled image 對 content image 取 loss,否則太嚴格,很難有空間學到我們要的風格。
加了 content loss 後,成功有了看得見的 output:
上面加了 content loss 後,可以注意到一個問題:因為我們讓 content 更接近原本的 image,所以 style 學到的就比較少了。
為了尋找最好的 setting,我們對 cycle-consistency loss、identity loss、以及 content loss 分別加了 weights,並尋找最好的組合。這邊 identity loss 是另一個確保色調不會跑掉的限制。Content loss 的 weight 稱作 。
接著因為 style image 樣本太少,我們做了 data augmentation,藉由旋轉、縮放等等來增加豐富性。
我們比較一下不同 和 data augmentation 的效果:
上面兩排有不同 , 大一點的比較強調 content 完整性。而 output 看起來確實 的比較少斷裂的線條。
下面兩排比較 data augmentation 的效果。可以看出有做 data augmentation 的,風格更接近我們要的粉筆風。
最後來看一下綜合評比:
—— 最上面是原始中文字。第二、三排是前面介紹的兩種 model,最下面是 CycleGAN 的三種 settings。
整體來看 CycleGAN 效果肯定是最佳。Neural style transfer 學到了一點 style 但 content 支離破碎。Image transformation network 則是沒怎麼學到 style transfer。而 CycleGAN 大致都看得出是哪個中文字,且都有些許粉筆風格。
—— CycleGAN 的三種 settings 訓練中的結果。
再來細看 CycleGAN 的結果。圖中為訓練 5 個 epoch 的中途結果。CycleGAN 的三個 setting,spec_norm
和 no_spec_norm
分別是加和不加 spectral normalization,一的增加訓練穩定性的技巧。high_beta
則是把 從 1 提高到 5。
Spectral normalization 效果看起來差異不大。提高 確實讓 content 更完整,而 style 方面訓練越久有機會能學得越好。反之如果 太低,content 損失似乎隨著訓練越久會越來越多。把 設高再藉由訓練久一點轉換更多風格似乎是最佳解。
最後我們也做了量化的評比:
—— CycleGAN 量化結果。
OCR 是文字圖像辨識,辨識度越高代表 output 越像文字。Style loss 是風格差距。後面兩個是人類評比的 content 和 style 分數,5 為滿分。
可以看到 CycleGAN 的表現相當不錯,在人類評比上可以說是成功的達成了字型風格轉換的任務。
我們簡單看一下 code,可以更清楚整個流程。會把一些不重要的東西拿掉,所以完整版要去 GitHub 看。大部分是參考 aitorzip/PyTorch-CycleGAN。
首先是 generator,整個架構是 input - downsampling - residual blocks - upsampling - output
,先 encode 再 decode 的概念。Upsampling 和 downsampling 由幾個 convolution block 組成,內部是 convolution - normalization - activation
的結構:
class Generator(nn.Module):
def __init__(self, input_nc=3, output_nc=3, n_residual_blocks=8):
super(Generator, self).__init__()
# Initial convolution block
conv_block_1 = [('refpad1', nn.ReflectionPad2d(3)),
('conv1', nn.Conv2d(input_nc, 64, 7)),
('norm1', nn.InstanceNorm2d(64)),
('relu1', nn.ReLU(inplace=True)) ]
# Downsampling
in_features = 64
out_features = in_features * 2
conv_blocks = []
for i in range(2):
conv_blocks += [('conv' + str(i + 2), nn.Conv2d(in_features, out_features, 3, stride=2, padding=1)),
('norm' + str(i + 2), nn.InstanceNorm2d(out_features)),
('relu' + str(i + 2), nn.ReLU(inplace=True)) ]
in_features = out_features
out_features = in_features * 2
# Residual blocks
res_blocks = []
for i in range(n_residual_blocks):
res_blocks += [('resblk' + str(i + 1), ResidualBlock(in_features))]
# Upsampling
deconv_blocks = []
out_features = in_features // 2
for i in range(2):
deconv_blocks += [('convt' + str(i + 1), nn.ConvTranspose2d(in_features, out_features, 3, stride=2, padding=1, output_padding=1)),
('normt' + str(i + 1), nn.InstanceNorm2d(out_features)),
('relut' + str(i + 1), nn.ReLU(inplace=True)) ]
in_features = out_features
out_features = in_features // 2
# Output layer
output_blocks = [('outrefpad', nn.ReflectionPad2d(3)),
('outconv', nn.Conv2d(64, output_nc, 7)),
('outtanh', nn.Tanh()) ]
self.conv_init = nn.Sequential(OrderedDict(conv_block_1))
self.conv = nn.Sequential(OrderedDict(conv_blocks))
self.res = nn.Sequential(OrderedDict(res_blocks))
self.deconv = nn.Sequential(OrderedDict(deconv_blocks))
self.output = nn.Sequential(OrderedDict(output_blocks))
self.apply(weights_init_normal)
def forward(self, x):
z = self.conv_init(x) # lower level conv layer as content
y = self.conv(z)
y = self.res(y)
y = self.deconv(y)
y = self.output(y)
return y, z
Discriminator 跟典型的 classification CNN 一樣,提取特徵後接 fully-connected layer 輸出判別結果:
class Discriminator(nn.Module):
def __init__(self, input_nc=3):
super(Discriminator, self).__init__()
# A bunch of convolutions one after another
in_features = input_nc
out_features = 64
model = [ nn.Conv2d(in_features, out_features, 4, stride=2, padding=1),
nn.LeakyReLU(0.2, inplace=True) ]
model += [ nn.Conv2d(out_features, out_features * 2, 4, stride=2, padding=1),
nn.InstanceNorm2d(out_features * 2),
nn.LeakyReLU(0.2, inplace=True) ]
model += [ nn.Conv2d(out_features * 2, out_features * 4, 4, stride=2, padding=1),
nn.InstanceNorm2d(out_features * 4),
nn.LeakyReLU(0.2, inplace=True) ]
model += [ nn.Conv2d(out_features * 4, out_features * 8, 4, padding=1),
nn.InstanceNorm2d(out_features * 8),
nn.LeakyReLU(0.2, inplace=True) ]
# FCN classification layer
model += [nn.Conv2d(512, 1, 4, padding=1)]
self.model = nn.Sequential(*model)
self.apply(weights_init_normal)
def forward(self, x, crop_image=False, crop_type=None):
if crop_image:
x = crop(x, crop_type)
x = self.model(x)
return F.avg_pool2d(x, x.size()[2:]).view(x.size()[0])
Training 流程跟前一篇提到的 pseudo-code 很類似,定義兩組 generator 和 discriminator 後,generator 和 discriminator 交互訓練。不含額外加的 technique 的話,一般 CycleGAN 的 generator loss 大概是 discriminator 給的 GAN loss + cycle-consistency loss:
# GAN loss
fake_Y, content_real_X = netG_X2Y(real_X)
pred_fake = netD_Y(fake_Y)
loss_GAN_X2Y = criterion_GAN(pred_fake, target_real)
fake_X, content_real_Y = netG_Y2X(real_Y)
pred_fake = netD_X(fake_X)
loss_GAN_Y2X = criterion_GAN(pred_fake, target_real)
# Cycle loss
recovered_X, content_fake_Y = netG_Y2X(fake_Y)
loss_cycle_XYX = criterion_cycle(recovered_X, real_X)
recovered_Y, content_fake_X = netG_X2Y(fake_X)
loss_cycle_YXY = criterion_cycle(recovered_Y, real_Y)
# Total loss
loss_G = loss_GAN_X2Y + loss_GAN_Y2X + loss_cycle_XYX + loss_cycle_YXY
Discriminator loss 則是真實 data 的 real loss + generator output 的 fake loss。這邊示範 , 作法亦同:
# Real loss
pred_real = netD_X(real_X)
loss_D_real = criterion_GAN(pred_real, target_real)
# Fake loss
fake_X = fake_X_buffer.push_and_pop(fake_X)
pred_fake = netD_X(fake_X.detach())
loss_D_fake = criterion_GAN(pred_fake, target_fake)
# Total loss
loss_D_X = (loss_D_real + loss_D_fake) * 0.5
這邊 fake_X_buffer
是在 generator 訓練過程中收集他產生的 output,在訓練 discriminator 時就可以從 buffer 隨機 sample 一筆 fake data。
整個 project 從最基本 CycleGAN 開始,隨著觀察到生成結果的問題想出一些解法,一步步改善 model,最後有了不錯的 style transfer 效果。很多 deep learning project 也差不多是這樣,不是直接使用某個架構就能獲得神奇的效果,還有很多協助訓練的小技巧可以加入,還有專屬於你的 task 的問題要解決。
而解決問題的那一刻,會很有成就感!