Neural Style Transfer com Torch

library(torch)
library(torchvision)
library(zeallot)
device <- torch_device(if(cuda_is_available()) "cuda" else "cpu")
cpu <- torch_device("cpu")

Neural Style Transfer é uma das técnicas mais divertidas e “artísticas” do deep learning. A imagem abaixo resume o que a gente vai fazer.

content

Você fornece duas imagens à rede neural: style e content e o resultado será a imagem content com o estilo de style. É como se fosse um filtro do instagram, mas com o estilo do seu artista predileto =P.

Este post é uma adaptação para R + torch do exercício do curso ‘Convolutional Neural Networks’ do deeplearning.ai que eu fiz, originalmente em Python + tensorflow. Quando comecei a escrever esse post, tinha a intenção de ser o mais didático possível, mas acho que eu não conseguiria superar o curso do Coursera, então vou me ater aos principais pontos e comentar o código para quem quiser criar suas próprias imagens! Além disso, este vídeo em português mostra um sumário da estratégia do NST.

PS: durante a escrita desse post eu descobri que o Daniel já tinha feito um codigozinho de NST para o torchvision. Parte do código roubei de lá kkk.

Conceitos e ideias importantes

A ideia é gerar uma imagem generated G que tenha conteúdo similar a content C e estilo similar a style S. O conteúdo e o estilo são, geralmente, extraídos de uma convnet pré-treinada. O artigo original (Gatys, Ecker, and Bethge 2015) utiliza o VGG19, que já vem dentro do {torchvision}.

# VGG19 model
vgg <- model_vgg19(pretrained = TRUE)$features$to(device = device)

# congelando os pesos
vgg$parameters %>% purrr::walk(function(param) param$requires_grad_(FALSE))

Então o VGG19 vai nos fornecer as features e agora precisamos definir funções de custo para achar a imagem que tem o melhor compromisso entre o conteúdo de uma imagem e o estilo de outra. A construção do algoritmo pode ser divida em três partes:

  • Função de custo do content: \(L_{content}(C, G)\)
  • Função de custo do style: \(L_{style}(S, G)\)
  • Função de custo global: \(L(G) = \alpha L_{content}(C, G) + \beta L_{style}(S, G)\)

Função de custo do content

Sobre a escolha entre camadas rasas versus camadas profundas:

  • As primeiras camadas de uma rede convolucional tendem a extrair padrões mais básicos como bordas e texturas simples.
  • Camadas mais profundas costumam captar características mais sofisticadas como texturas detalhadas e objetos.

E sobre a escolha de uma camada do meio, queremos a imagem generated com conteúdo similar ao content. A estratégia é:

  1. escolher uma camada da rede para representar este tal “conteúdo.”
  2. pegar essa camada para cada uma das imagens content e generated.
  3. fazer a rede forçar com que essas duas camadas sejam o mais parecidas possível.

Então a função de custo para refletir o ponto (3) pode ser simplesmente o erro quadrático médio entre os pixels dessa camada:

content_loss <- function(content_layer, generated_layer) {
  nnf_mse_loss(content_layer, generated_layer)
}

OBS: Na prática, você irá obter o resultado “mais legal” se escolher camadas da meiúca: nem tão rasa, nem tão profunda. Já que a VGG19 possui 37 camadas, a escolhida pode ser, por exemplo, a camada 14.

Função de custo do style

A finalidade da função de custo do style é minimizar a distância entre as tais Gram matrix das imagens style e generated.

Gram matrix

A matriz de estilo é chamada de Gram matrix na literatura. Na matemática, dado um conjunto de vetores, a Gram matrix é matriz dos produtos internos dos pares destes vetores. É como se fosse uma matriz de correlação, mas sem centralizar pela média. Na prática, pega-se uma camada da rede, transforma em um tensor 2D (matriz) e calcula \(A * A^T\).

gram_matrix <- function(tensor) {
  c(b,c,h,w) %<-% tensor$size()
  tensor <- tensor$view(c(c, h*w))
  torch_matmul(tensor, tensor$t())/(h*w)
}

A função de custo \(L_{style}(S, G)\) é o bom e velho erro quadrático médio entre as Gram matrices.

style_loss <- function(style_layer, generated_layer) {
  style_gram <- gram_matrix(style_layer)
  generated_gram <- gram_matrix(generated_layer)
  nnf_mse_loss(style_gram, generated_gram)
}

Camadas dos estilos

Em vez de uma, pega-se cinco camadas intermediárias da rede para calcular as distâncias entre as respectivas Gram matrices. A função de custo vai passar a ser uma ponderação dessas cinco distâncias: \(L_{style}(S, G) = \sum_{l=1}^{5}\lambda^{[l]}L_{style}^{[l]}(S,G)\)

style_layers <- c(2, 7, 12, 21, 29) # escolha das layers da VGG
lambdas <- 1e5/(c(1,10,10,10,10)^2) # pesos para cada uma das layers no estilo final

Agora vale criar uma nn_module() que retorne as camadas intermediárias da rede (no caso VGG19). O argumento layers_out permite escolher quais camadas queremos trazer.

features <- nn_module(
  initialize = function(convnet) {
    # poderia ser qualquer convnet pré-treinada. Iremos usar a VGG19
    self$convnet <- convnet
  },
  forward = function(img, layers_out = NULL) {
    layers <- seq_along(self$convnet) # 1 a 37
    if(is.null(layers_out)) layers_out <- layers
    conv_outs <- purrr::accumulate(layers, ~self$convnet[[.y]](.x), .init = img) # lista de 37 tensores
    conv_outs[layers_out] # lista apenas com as layers selecionadas
  }
)

Otimização

Abaixo segue código comentado para rodar a otimização.

#funções auxiliares
to_r <- function(x) as.numeric(x$to(device = cpu))
                               
plot_image <- function(tensor) {
  im <- tensor$to(device = "cpu")[1,..]$
    permute(c(2, 3, 1))$
    to(device = "cpu")$
    clamp(0,1) %>% # make it [0,1]
    as.array()
  par(mar = c(0,0,0,0))
  plot(as.raster(im))
}

load_image <- function(path, geometry = "250x200") {
  img <- path %>%
    magick_loader() %>%
    magick::image_resize(geometry) %>%
    transform_to_tensor() %>%
    torch_unsqueeze(1)
  
  img$to(device = device)
}

Os parâmetros e inputs que valem a pena experimentar são:

  • content e style: Imagens de input: a de conteúdo e a de estilo.
  • content_layer: a layer da VGG19 para extrair o conteúdo imagem de conteúdo.
  • style_layers: as layers da VGG19 para extrair o estilo imagem de estilo.
  • lambdas: os pesos de cada feature de estilo na otimização.
  • content_weight: o peso das features de conteúdo na otimização.
  • style_weight: o peso das features de estilo (global) na otimização.

Outros dois parâmetros que afetam drasticamente o resultado são as dimensões das duas imagens de input. No código abaixo onde tem "400x400" e "350x500" pode-se trocar por outras dimensões a fim de se obter resultados diferentes.

No processo de criação você irá mexer nesses parâmetros o tempo todo!

# INPUT: content and style images
content <- load_image("content/posts/2021-02-22-neural-style-transfer/cristoredentor3.jpg", "400x400")
style <- load_image("content/posts/2021-02-22-neural-style-transfer/vangogh_starry_night.jpg", "350x500")

# style and content feature setup
content_layer <- 14
style_layers <- c(2, 7, 12, 21, 29)
lambdas <- 1e5/(c(1,10,10,10,10)^2)
content_weight <- 2
style_weight <- 4e-1

content

style

# FEATURES: extraídas da VGG19
vgg_features <- features(vgg)
content_features <- vgg_features(content, content_layer)
style_features <- vgg_features(style, style_layers)

# OUTPUT: generated image
generated <- torch_clone(content)$requires_grad_(TRUE)
optim <- optim_adam(generated, lr = 0.02)
lr_scheduler <- lr_step(optim, 100, 0.9)

# loop de otimização
for(step in seq_len(8000)) {
  optim$zero_grad()
  # atualiza as features da imagem que está sendo gerada
  generated_features <- vgg_features(generated, c(content_layer, style_layers))
  
  # losses
  LC <- content_loss(content_features[[1]], generated_features[[1]])
  LS <- 0
  for(i in seq_along(lambdas)) 
    LS <- LS + lambdas[i]*style_loss(style_features[[i]], generated_features[-1][[i]])  
  
  loss <- content_weight * LC + style_weight * LS
  
  loss$backward()
  optim$step()
  lr_scheduler$step()
  
  # feedback
  if(step %% 100 == 0) {
    cat(glue::glue("LC = {to_r(LC)} - LS = {to_r(LS)} - Loss = {to_r(loss)}\n\n"))
    plot_image(generated)
  }
}

# imagem final
plot_image(generated)
LC = 2.70741701126099 - LS = 36.7998428344727 - Loss = 20.1347713470459
LC = 2.61282753944397 - LS = 24.3190288543701 - Loss = 14.9532661437988
LC = 2.54735898971558 - LS = 19.7909469604492 - Loss = 13.0110969543457
LC = 2.49572896957397 - LS = 16.9353790283203 - Loss = 11.7656097412109
LC = 2.44669985771179 - LS = 16.6177845001221 - Loss = 11.5405139923096

E aí? Ficou com cara de pintura? Comente o que achou! Tente com suas imagens e compartilhe com a gente =). Aprender como NST funciona é um grande exercício para aprimorar o entendimento sobre modelos de deep learning em geral, principalmente sobre como podemos criar funções de custo mais especializadas em um determinado contexto.

Aprenda Deep learning com a Curso-R

Se você quiser entrar no incrível mundo das redes profundas, nosso curso de Deep Learning é a melhor porta de entrada, conheça!

Gatys, Leon A., Alexander S. Ecker, and Matthias Bethge. 2015. “A Neural Algorithm of Artistic Style.” http://arxiv.org/abs/1508.06576.
comments powered by Disqus