Pesquisa de site

Preenchimento em redes neurais convolucionais


O preenchimento é um processo essencial em Redes Neurais Convolucionais. Embora não seja obrigatório, é um processo frequentemente utilizado em muitas arquiteturas CNN de última geração. Neste artigo, exploraremos por que e como isso é feito.

O Mecanismo de Convolução

A convolução em um contexto de processamento de imagem/visão computacional é um processo pelo qual uma imagem é “escaneada” por um filtro para processá-la de alguma forma. Vamos ser um pouco técnicos com os detalhes.

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

Quando uma imagem é convoluída, um filtro é aplicado em trechos sequenciais da imagem onde a multiplicação por elemento ocorre entre os elementos do filtro e os pixels nesse trecho, uma soma cumulativa é então retornada como um novo pixel próprio. Por exemplo, ao realizar a convolução usando 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.

Filtre a varredura em uma imagem para gerar uma nova imagem por meio de convolução.

Os pixels perdidos

Para entender por que os pixels são perdidos, tenha em mente que se um filtro de convolução sair dos limites ao digitalizar uma imagem, essa instância de convolução específica será ignorada. Para ilustrar, considere uma imagem de 6 x 6 pixels sendo envolvida por um filtro de 3 x 3. Como pode ser visto na imagem abaixo, as primeiras 4 convoluções caem dentro da imagem para produzir 4 pixels para a primeira linha, enquanto a 5ª e a 6ª instâncias ficam fora dos limites e são, portanto, ignoradas. Da mesma forma, se o filtro for deslocado 1 pixel para baixo, o mesmo padrão será repetido com uma perda de 2 pixels também para a segunda linha. Quando o processo for concluído, a imagem de 6 x 6 pixels se tornará uma imagem de 4 x 4 pixels, pois 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 3x3.

Da mesma forma, se um filtro 5 x 5 for usado, 4 colunas e linhas de pixels serão perdidas em 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 5x5.

Não acredite apenas na minha palavra, experimente a função abaixo para ver se esse é realmente o caso. Sinta-se à vontade para ajustar os argumentos conforme desejar.

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
    """
    #  creating pseudo image
    original_image = torch.ones(image_size)
    #  adding channel as typical to images (1 channel = grayscale)
    original_image = original_image.view(1, 6, 6)

    #  perfoming convolution
    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. Parece que sempre que um filtro m x n é usado, m-1 colunas de pixels são perdidas em dim 0 e n-1 linhas de pixels são perdidas em dim 1. Vamos ser um pouco mais matemáticos…

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

Sempre que uma imagem de tamanho (x, y) é convoluída usando um filtro de tamanho (m, n), uma imagem de tamanho (xm+1, yn+1) é produzida.

Embora essa equação possa parecer um pouco complicada (sem trocadilhos), a lógica por trás dela é bastante simples de seguir. Como os filtros mais comuns são de tamanho quadrado (mesmas dimensões em ambos os eixos), tudo o que há a saber é que uma vez feita a convolução usando um filtro (3, 3), 2 linhas e colunas de pixels são perdidas (3-1); se for feito com filtro (5, 5), perdem-se 4 linhas e colunas de pixels (5-1); e se for feito usando um filtro (9, 9), você adivinhou, 8 linhas e colunas de pixels serão perdidas (9-1).

Implicação de pixels perdidos

A perda de 2 linhas e colunas de pixels pode não parecer ter tanto efeito, especialmente ao lidar com imagens grandes, por exemplo, uma imagem 4K UHD (3840, 2160) pareceria não ser afetada pela perda de 2 linhas e colunas de pixels quando envolvido por um filtro (3, 3) à medida que se torna (3838, 2158), uma perda de cerca de 0,1% do total de pixels. Os problemas começam a surgir quando múltiplas camadas de convolução estão envolvidas, como é típico nas arquiteturas CNN de última geração. Tomemos como exemplo o RESNET 128, esta arquitetura possui cerca de 50 (3, 3) camadas de convolução, o que resultaria em uma perda de cerca de 100 linhas e colunas de pixels, reduzindo o tamanho da imagem para (3740, 2060), uma perda de aproximadamente 7,2% do total de pixels da imagem, tudo sem levar em consideração operações de redução da resolução.

Mesmo com arquiteturas superficiais, a perda de pixels pode ter um efeito enorme. Uma CNN com apenas 4 camadas de convolução aplicadas usadas em uma imagem do conjunto de dados MNIST com tamanho (28, 28) resultaria em uma perda de 8 linhas e colunas de pixels, reduzindo seu tamanho para (20, 20), uma perda de 57,1 % do total de pixels, o que é bastante considerável.

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

Preenchimento como solução

Como sabemos que os pixels serão perdidos após a convolução, podemos evitar isso adicionando pixels de antemão. Por exemplo, se um filtro (3, 3) for usado, poderíamos adicionar 2 linhas e 2 colunas de pixels à imagem de antemão para que quando a convolução for feita o tamanho da imagem seja igual ao da imagem original.

Vamos ser um pouco matemáticos novamente…

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

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

usando a equação ==> (xm+1, yn+1)

tamanho da imagem após 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 igual a zero, por isso o termo 'preenchimento de zero' é frequentemente usado.

O problema de adicionar preventivamente linhas e colunas de pixels a uma matriz de imagens é que isso deve 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 na parte superior, uma linha na parte inferior, uma coluna à esquerda e uma coluna à direita.

Olhando para a imagem abaixo, 2 linhas e 2 colunas foram adicionadas para preencher a matriz 6 x 6 à 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 indicado no parágrafo anterior.

Olhando atentamente para os arrays, à esquerda, parece que o array 6 x 6 de uns foi encerrado em uma única camada de zeros, então preenchimento=1. Por outro lado, a matriz à direita parece ter sido encerrada em duas camadas de zeros, portanto, preenchimento=2.

Camadas de zeros adicionadas por meio de preenchimento.

Juntando tudo isso, é seguro dizer que quando se pretende adicionar 2 linhas e 2 colunas de pixels na preparação para a convolução (3, 3), é necessária uma única camada de preenchimento. Na mesma palheta, se for necessário adicionar 6 linhas e 6 colunas de pixels em preparação para a convolução (7, 7), serão necessárias 3 camadas de preenchimento. Em termos mais técnicos,

Dado um filtro de tamanho (m, n), (m-1)/2 camadas de preenchimento são necessárias para manter o mesmo tamanho da imagem após a convolução; desde que m=n e m seja um número ímpar.

O processo de preenchimento

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

Primeiramente, vamos dar uma olhada na função de preenchimento abaixo, a função recebe uma imagem como parâmetro com uma camada de preenchimento padrão de 2. Quando o parâmetro display é deixado como True, a função gera um mini relatório exibindo o tamanho de ambos os imagem original e acolchoada; um gráfico 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.
      """

      #  reading image as grayscale
      image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)

      #  creating an array of zeros
      padded = arr = np.zeros((image.shape[0] + padding*2,
                               image.shape[1] + padding*2))

      #  inserting image into zero array
      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}')

        #  displaying results
        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 do tamanho (375, 500). Passar esta imagem pela função de preenchimento com padding=2 deve produzir a mesma imagem com duas colunas de zero nas bordas esquerda e direita e duas linhas de zeros na parte superior e inferior, aumentando o tamanho da imagem para (379, 504). Vamos ver se é esse o caso…

Imagem de tamanho (375, 500)

pad_image('image.jpg')

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

Observe a linha fina de pixels pretos ao longo das bordas da imagem preenchida.

Funciona! Sinta-se à vontade para experimentar a função em qualquer imagem que encontrar e ajustar os parâmetros conforme necessário. Abaixo está o código vanilla para replicar a convolução.

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

      #  reading image as grayscale
      image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)

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

      #  defining filter size
      filter_size = filter.shape[0]

      #  creating an array to store convolutions
      convolved = np.zeros(((image.shape[0] - filter_size) + 1,
                        (image.shape[1] - filter_size) + 1))

      #  performing convolution
      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

      #  displaying results
      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 optei por um array (5, 5) com valores de 0,01. A ideia por trás disso é que o filtro reduza as intensidades dos pixels em 99% antes de somar para produzir um único pixel. Em termos simplistas, 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

Aplicar o filtro na imagem original sem preenchimento deve produzir uma imagem desfocada de tamanho (371, 496), uma perda de 4 linhas e 4 colunas.

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

Executando 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 pad é 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 desta vez com um filtro (9, 9)…

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 reduz de 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 mesmo tamanho da imagem, precisamos especificar uma camada de preenchimento de 4 (9-1/2), pois procuraremos 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

De uma perspectiva PyTorch

Para facilitar a ilustração, optei por explicar os processos usando código vanilla na seção acima. O mesmo processo pode ser replicado no PyTorch, tendo em mente que a imagem resultante provavelmente sofrerá pouca ou nenhuma transformação, pois o PyTorch inicializará aleatoriamente um filtro que não foi 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()

    #  adding channel as typical to images (1 channel = grayscale)
    image = image.view(1, image.shape[0], image.shape[1])

    #  perfoming convolution
    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 executa convolução usando a classe de convolução PyTorch padrão

Observe que na função usei a classe de convolução 2D padrão PyTorch e o parâmetro de preenchimento da função é fornecido diretamente para a classe de convolução. Agora vamos tentar filtros diferentes e ver quais são os tamanhos de imagem 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 uma camada 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-

saída: tamanho da imagem original: 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 preenchimento-

saída: tamanho da imagem original: 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 preenchimento, a imagem resultante tem um tamanho reduzido. No entanto, quando a convolução é feita com a quantidade correta de camadas de preenchimento, a imagem resultante tem tamanho igual à imagem original.

Considerações Finais

Neste artigo pudemos verificar que o processo de convolução resulta de fato na perda de pixels. Também conseguimos provar que adicionar pixels preventivamente a uma imagem, em um processo que chama de preenchimento, antes da convolução garante que a imagem retenha seu tamanho original após a convolução.

Artigos relacionados: