Padding em redes neurais convolucionais

Padding é um processo essencial em Redes Neurais Convolucionais. Embora não seja obrigatório, é um processo que frequentemente é usado em muitas arquiteturas de CNN de estado da arte. Neste artigo, vamos explorar por que e como é feito.

O Mecanismo de Convolução

A convolução, no contexto de processamento de imagens/visão computacional, é um processo pelo qual uma imagem é “rascada” por um filtro para a processar de alguma forma. Vamos ficar um pouco técnico com os detalhes.

Para um computador, uma imagem é simplesmente um array de tipos numéricos (números, inteiros ou reais), esses tipos numéricos são chamados de pixels. Na verdade, uma imagem HD de 1920 pixels por 1080 pixels (1080p) é apenas uma tabela/array de tipos numéricos com 1080 linhas e 1920 colunas. Um filtro, por outro lado, é essencialmente o mesmo, mas usualmente com dimensões menores, o filtro de convolução comum (3, 3) é um array de 3 linhas e 3 colunas.

Quando uma imagem é convolvida, um filtro é aplicado aos pedaços sequenciais da imagem onde a multiplicação elemento a elemento é realizada entre os elementos do filtro e os pixels desse pedaço, então é retornada uma soma acumulada como um novo pixel. Por exemplo, quando realizando convolução com um filtro (3, 3), 9 pixels são agregados para produzir um único pixel. Devido a este processo de agregação, alguns pixels são perdidos.

Scanning de filtro sobre uma imagem para gerar uma nova imagem via convolução.

Os Pixels Perdidos

Para entender por que os pixels são perdidos, lembre-se que se um filtro de convolução sai dos limites ao scanner uma imagem, essa instância particular de convolução é ignorada. Para ilustrar, considere uma imagem de 6 x 6 pixels sendo convolvida por um filtro de 3 x 3. Como pode ser visto na imagem abaixo, as primeiras 4 convoluções estão dentro da imagem para produzir 4 pixels para a primeira linha enquanto as quinta e sexta instâncias estão fora dos limites e são portanto ignoradas. Da mesma forma, se o filtro for movido para baixo em 1 pixel, o mesmo padrão é repetido com a perda de 2 pixels para a segunda linha também. Quando o processo está completo, a imagem de 6 x 6 pixels torna-se uma imagem de 4 x 4 pixels since teria perdido 2 colunas de pixels em dim 0 (x) e 2 linhas de pixels em dim 1 (y).

Instâncias de convolução usando um filtro de 3×3.

Ao mesmo tempo, se um filtro de 5 x 5 for usado, 4 colunas e linhas de pixels são perdidos em ambas dim 0 (x) e dim 1 (y) respectivamente, resultando em uma imagem de 2 x 2 pixels.

Instâncias de convolução usando um filtro de 5×5.

Não me acredite, tente a função abaixo para ver se isso é mesmo o caso. Sinta-se livre para ajustar argumentos conforme desejado.

import numpy as np
import torch
import torch.nn.functional as F
import cv2
import torch.nn as nn
from tqdm import tqdm
import matplotlib.pyplot as plt

def check_convolution(filter=(3,3), image_size=(6,6)):
    """
    This function creates a pseudo image, performs
    convolution and returns the size of both the pseudo
    and convolved image
    """
    # criando pseudo imagem
    original_image = torch.ones(image_size)
    # adicionando canal como comum em imagens (1 canal = escala de cinza)
    original_image = original_image.view(1, 6, 6)

    # executando convolução
    conv_image = nn.Conv2d(1, 1, filter)(original_image)

    print(f'original image size: {original_image.shape}')
    print(f'image size after convolution: {conv_image.shape}')
    pass

check_convolution()

Parece haver um padrão na forma como os pixels são perdidos. Sempre que um filtro m x n é usado, são perdidas m-1 colunas de pixels no eixo 0 e n-1 linhas de pixels no eixo 1. Vamos avançar um pouco mais com a matemática…

tamanho da imagem = (x, y)
tamanho do filtro = (m, n)
tamanho da imagem após a convolução = (x-(m-1), y-(n-1)) = (x-m+1, y-n+1)

Quando uma imagem de tamanho (x, y) é convolvida usando um filtro de tamanho (m, n), é produzida uma imagem de tamanho (x-m+1, y-n+1).

Enquanto essa equação possa parecer um pouco complicada (sem intenção de chutar), a lógica por trás dela é muito simples de seguir. Já que a maioria dos filtros comuns são quadrados em tamanho (mesmas dimensões em ambos os eixos), tudo o que precisa saber é que, assim que a convolução for feita usando um filtro (3, 3), 2 linhas e colunas de pixels são perdidas (3-1); se for feita usando um filtro (5, 5), 4 linhas e colunas de pixels são perdidas (5-1); e se for feita usando um filtro (9, 9), você adivinhou, 8 linhas e colunas de pixels são perdidas (9-1).

Implicações dos Pixels Perdidos

Perder 2 linhas e colunas de pixels pode não parecer ter muito efeito, particularmente quando se trata de imagens grandes, por exemplo, uma imagem 4K UHD (3840, 2160) pareceria não sendo afetada pela perda de 2 linhas e colunas de pixels quando for convolvida the um filtro (3, 3), pois torna-se (3838, 2158), uma perda de cerca de 0,1% de seus pixels totais. Os problemas começam a surgir quando há várias camadas de convolução envolvidas, como é típico em arquiteturas de CNN de ponta de arte. Tomem o RESNET 128, por exemplo, esta arquitetura tem cerca de 50 camadas de convolução (3, 3), o que resultaria na perda de cerca de 100 linhas e colunas de pixels, reduzindo o tamanho da imagem para (3740, 2060), uma perda de aproximadamente 7,2% dos pixels totais da imagem, sem considerar operações de downsampling.

Mesmo com arquiteturas profundas, perder pixels poderia ter um efeito grande. Um CNN com apenas 4 camadas de convolução aplicadas usado em uma imagem no conjunto de dados MNIST com tamanho (28, 28) resultaria na perda de 8 linhas e colunas de pixels, reduzindo seu tamanho para (20, 20), uma perda de 57,1% de seus pixels totais, o que é considerável.

Como as operações de convolução ocorrem da esquerda para a direita e do topo para baixo, os pixels são perdidos nas bordas direita e inferior. Portanto, é seguro dizer que convolução resulta na perda de pixels de borda, pixels que podem conter features essenciais para a tarefa de visão computacional abordada.

Padding como Solução

Já que sabemos que os pixels estão destinados a serem perdidos após a convolução, podemos antecipar isso adicionando pixels anteriormente. Por exemplo, se um filtro (3, 3) for usado, poderíamos adicionar 2 linhas e 2 colunas de pixels à imagem anteriormente, de modo que quando a convolução for feita, o tamanho da imagem seja o mesmo que a imagem original.

Vamos ficar um pouco matemático novamente…

tamanho da imagem = (x, y)
tamanho do filtro = (m, n)

tamanho da imagem após o preenchimento = (x+2, y+2)

usando a equação ==> (x-m+1, y-n+1)

tamanho da imagem após a convolução (3, 3) = (x+2-3+1, y+2-3+1) = (x, y)

Preenchimento em Termos de Camada

Como estamos lidando com tipos de dados numéricos, faz sentido que o valor dos pixels adicionais também seja numérico. O valor comum adotado é um valor de pixel zero, daí o termo ‘preenchimento zero’ ser frequentemente usado.

O ponto fraco de adicionar linhas e colunas de pixels a uma matriz de imagem antecipadamente é que isso precisa ser feito uniformemente em ambos os lados. Por exemplo, ao adicionar 2 linhas e 2 colunas de pixels, elas devem ser adicionadas como uma linha no topo, uma linha na parte inferior, uma coluna à esquerda e uma coluna à direita.

Observando a imagem abaixo, 2 linhas e 2 colunas foram adicionadas para preencher a matriz de 6 x 6 uns à esquerda, enquanto 4 linhas e 4 colunas foram adicionadas à direita. As linhas e colunas adicionais foram distribuídas uniformemente ao longo de todas as bordas, conforme declarado no parágrafo anterior.

Ao analisarmos as matrizes, na esquerda, parece que a matriz 6 x 6 deuns foi envolvida numa camada única de zeros, portanto, padding=1. Por outro lado, a matriz da direita parece ter sido envolvida em duas camadas de zeros, portanto, padding=2.

Camadas de zeros adicionadas por padding.

Juntando tudo isto, podemos dizer que quando se quer adicionar 2 linhas e 2 colunas de pixeis em preparação para uma convolução (3, 3), é necessário um único nível de padding. Da mesma forma, se for necessário adicionar 6 linhas e 6 colunas de pixeis em preparação para uma convolução (7, 7), é necessário 3 níveis de padding. Em termos técnicos,

Dada um filtro de tamanho (m, n), é necessário (m-1)/2 níveis de padding para manter o tamanho da imagem igual depois da convolução; desde que m=n e m seja um número ímpar.

O Processo de Padding

Para demonstrar o processo de padding, escrevi algum código básico para replicar o processo de padding e convolução.

Primeiro, vamos olhar para a função de padding abaixo, a função aceita uma imagem como parâmetro com um padding padrão de 2. Quando o parâmetro de exibição é deixado como Verdadeiro, a função gera um relatório mini mostrando o tamanho da imagem original e do padding; uma plot de ambas as imagens é também retornado.

def pad_image(image_path, padding=2, display=True, title=''):
      """
      This function performs zero padding using the number of
      padding layers supplied as argument and return the padded
      image.
      """

      # Lendo imagem em escala de cinza
      image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)

      # Criando um array de zeros
      padded = arr = np.zeros((image.shape[0] + padding*2,
                               image.shape[1] + padding*2))

      # Inserindo imagem no array de zeros
      padded[int(padding):-int(padding),
             int(padding):-int(padding)] = image

      if display:
        print(f'original image size: {image.shape}')
        print(f'padded image size: {padded.shape}')

        # Exibindo resultados
        figure, axes = plt.subplots(1,2, sharey=True, dpi=120)
        plt.suptitle(title)
        axes[0].imshow(image, cmap='gray')
        axes[0].set_title('original')
        axes[1].imshow(padded, cmap='gray')
        axes[1].set_title('padded')
        axes[0].axis('off')
        axes[1].axis('off')
        plt.show()
        print('image array preview:')
      return padded

Função de preenchimento.

Para testar a função de preenchimento, considere a imagem abaixo de tamanho (375, 500). Passando esta imagem pela função de preenchimento com padding=2 deveria resultar na mesma imagem com dois columnas de zeros a esquerda e direita e duas linhas de zeros no topo e na base, aumentando o tamanho da imagem para (379, 504). Vamos ver se é o caso…

Imagem de tamanho (375, 500)

pad_image('image.jpg')

saída:
tamanho original da imagem: (375, 500)
tamanho da imagem preenchida: (379, 504)

Note a fina linha de pixels pretos ao longo dos bordes da imagem preenchida.

Função funciona! Fique livre para testar a função em qualquer imagem que você puder encontrar e ajustar parâmetros conforme necessário. Abaixo está o código simples para replicar o processo de convolução.

def convolve(image_path, padding=2, filter, title='', pad=False):
      """
      This function performs convolution over an image
      """

      # Lendo imagem em escala de cinza
      image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)

      if pad:
        original_image = image[:]
        image = pad_image(image, padding=padding, display=False)
      else:
        image = image

      # Definindo o tamanho do filtro
      filter_size = filter.shape[0]

      # Criando um array para armazenar convoluções
      convolved = np.zeros(((image.shape[0] - filter_size) + 1,
                        (image.shape[1] - filter_size) + 1))

      # Realizando convolução
      for i in tqdm(range(image.shape[0])):
        for j in range(image.shape[1]):
          try:
            convolved[i,j] = (image[i:(i+filter_size), j:(j+filter_size)] * filter).sum()
          except Exception:
            pass

      # Exibindo resultados
      if not pad:
        print(f'original image size: {image.shape}')
      else:
        print(f'original image size: {original_image.shape}')
      print(f'convolved image size: {convolved.shape}')

      figure, axes = plt.subplots(1,2, dpi=120)
      plt.suptitle(title)
      if not pad:
        axes[0].imshow(image, cmap='gray')
        axes[0].axis('off')
      else:
        axes[0].imshow(original_image, cmap='gray')
        axes[0].axis('off')
      axes[0].set_title('original')
      axes[1].imshow(convolved, cmap='gray')
      axes[1].axis('off')
      axes[1].set_title('convolved')
      pass

Função de convolução

Para o filtro escolhido, decidi usar uma matriz (5, 5) com valores de 0.01. A ideia por trás disto é que o filtro reduza as intensidades dos pixels em 99% antes de somar para produzir um único pixel. Em termos simples, este filtro deveria ter um efeito de desfoque nas imagens.

filter_1 = np.ones((5,5))/100

filter_1
[[0.01, 0.01, 0.01, 0.01, 0.01]
 [0.01, 0.01, 0.01, 0.01, 0.01]
 [0.01, 0.01, 0.01, 0.01, 0.01]
 [0.01, 0.01, 0.01, 0.01, 0.01]
 [0.01, 0.01, 0.01, 0.01, 0.01]]

(5, 5) Filtro de Convolução

Aplicando o filtro na imagem original sem preenchimento deveria resultar em uma imagem desfocada de tamanho (371, 496), com a perda de 4 linhas e 4 colunas.

convolve('image.jpg', filter=filter_1)

Realizando a convolução sem preenchimento

saída:
tamanho da imagem original: (375, 500)
tamanho da imagem convolvida: (371, 496)

(5, 5) convolução sem preenchimento

No entanto, quando o preenchimento é definido como verdadeiro, o tamanho da imagem permanece o mesmo.

convolve('image.jpg', pad=True, padding=2, filter=filter_1)

Convolução com 2 camadas de preenchimento.

saída:
tamanho da imagem original: (375, 500)
tamanho da imagem convolvida: (375, 500)

(5, 5) convolução com preenchimento

Vamos repetir os mesmos passos, mas agora com um filtro (9, 9) desta vez…

filter_2 = np.ones((9,9))/100
filter_2

filter_2
[[0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
 [0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
 [0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
 [0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
 [0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
 [0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
 [0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
 [0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
 [0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01]])

(9, 9) filtro

Sem preenchimento, a imagem resultante diminui em tamanho…

convolve('image.jpg', filter=filter_2)

saída:
tamanho da imagem original: (375, 500)
tamanho da imagem convolvida: (367, 492)

(9, 9) convolução sem preenchimento

Usando um filtro (9, 9), para manter o tamanho da imagem o mesmo, precisamos especificar uma camada de preenchimento de 4 (9-1/2), já que iremos adicionar 8 linhas e 8 colunas à imagem original.

convolve('image.jpg', pad=True, padding=4, filter=filter_2)

saída:
tamanho da imagem original: (375, 500)
tamanho da imagem convolvida: (375, 500)

(9, 9) convolução com preenchimento

Perspectiva de PyTorch

Para fins de ilustração fácil, eu escolhi explicar os processos usando código simples acima. O mesmo processo pode ser replicado em PyTorch, lembre-se porém que a imagem resultante provavelmente sofrerá pouca ou nenhuma transformação, já que PyTorch inicializará aleatoriamente um filtro que não está projetado para nenhuma finalidade específica.

Para demonstrar isso, vamos modificar a função check_convolution() definida em uma das seções anteriores acima…

def check_convolution(image_path, filter=(3,3), padding=0):
    """
    This function performs convolution on an image and
    returns the size of both the original and convolved image
    """

    image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)

    image = torch.from_numpy(image).float()

    #  adicionando canal como típico em imagens (1 canal = escala de cinza)
    image = image.view(1, image.shape[0], image.shape[1])

    #  realizando convolução
    with torch.no_grad():
      conv_image = nn.Conv2d(1, 1, filter, padding=padding)(image)

    print(f'original image size: {image.shape}')
    print(f'image size after convolution: {conv_image.shape}')
    pass

Função realiza convolução usando a classe de convolução 2D padrão de PyTorch

Note que na função eu usei a classe de convolução 2D padrão de PyTorch e o parâmetro de preenchimento da função é fornecido diretamente à classe de convolução. Agora vamos tentar diferentes filtros e ver quais são os tamanhos das imagens resultantes…

check_convolution('image.jpg', filter=(3, 3))

(3, 3) convolução sem preenchimento

saída:
tamanho da imagem original: torch.Size(1, 375, 500)
tamanho da imagem após convolução: torch.Size(1, 373, 498)


check_convolution('image.jpg', filter=(3, 3), padding=1)

(3, 3) convolução com um nível de preenchimento.-

saída:
tamanho da imagem original: torch.Size(1, 375, 500)
tamanho da imagem após convolução: torch.Size(1, 375, 500)

check_convolution('image.jpg', filter=(5, 5))

(5, 5) convolução sem preenchimento-

tamanho original da imagem: torch.Size(1, 375, 500)
tamanho da imagem após convolução: torch.Size(1, 371, 496)

check_convolution('image.jpg', filter=(5, 5), padding=2)

(5, 5) convolução com 2 camadas de padding-

tamanho original da imagem: torch.Size(1, 375, 500)
tamanho da imagem após convolução: torch.Size(1, 375, 500)

Como é evidente nos exemplos acima, quando a convolução é feita sem padding, a imagem resultante tem tamanho reduzido. No entanto, quando a convolução é feita com a quantidade correta de camadas de padding, a imagem resultante tem o mesmo tamanho que a imagem original.

Observações Finais

Neste artigo, conseguimos afirmar que o processo de convolução na verdade resulta em perda de pixels. Conseguimos também provar que adicionar pixels à imagem, em um processo chamado padding, antes da convolução garante que a imagem mantém seu tamanho original após a convolução.

Source:
https://www.digitalocean.com/community/tutorials/padding-in-convolutional-neural-networks