Técnicas de processamento digital de imagens no Python com matemática

Alvaro Leandro Cavalcante Carneiro
Data Hackers
Published in
10 min readSep 30, 2020
Photo by Roman Mager on Unsplash

As técnicas de processamento digital de imagens são de grande importância nas mais variadas áreas de aplicação, desde o uso de filtros para melhorias visuais até técnicas de data augmentation utilizadas no aprendizado de máquina.

Embora o avanço constante dessa área tenha trazido inúmeras bibliotecas nas mais diversas linguagens que permitam realizar transformações nas imagens com apenas poucas linhas de código, entender as bases matemáticas e teóricas são fundamentais para quem quer dar um passo além e criar as suas próprias soluções.

Pensando nisso, o intuito desse artigo é mostrar algumas técnicas populares de transformação geométrica em imagens com suas respectivas fórmulas matemáticas e a implementação passo a passo no Python. Recomendo fortemente o uso de IDE’s como o Spyder ou algum debuger para que você consiga visualizar em detalhes como essas técnicas são aplicadas nas imagens.

Transformações geométricas

Pretendo trazer outras técnicas em futuros artigos, porém aqui iremos nos limitar às transformações geométricas. Basicamente essas técnicas consistem em alterar a posição espacial dos pixels, geralmente sem distorcer a figura na imagem. Sendo assim, dado um pixel em uma imagem bidimensional com um valor de intensidade entre 0 e 255 e nas coordenadas(X,Y) iremos utilizar fórmulas matemáticas para descobrir as novas coordenadas (X’,Y’) e o valor de intensidade do pixel na imagem que será gerada.

Para criar esse rearranjo nos pixels podemos utilizar o mapeamento direto ou indireto

Mapeamento direto

O mapeamento direto consiste na varredura de cada um dos pixels da imagem de entrada e, para cada posição, calculamos sua localização correspondente na nova imagem gerada. O problema dessa técnica é que os pixels podem ser transportados para uma mesma posição espacial na nova imagem. Isso acontece pelo fato de que valores espaciais de X e Y devem ser discretos e não contínuos, portanto é preciso arredondá-los. Por exemplo, não existe a posição de 3,5 X e 4,2 Y. Sendo assim, esse mapeamento gera uma perda de informação que pode ser visualizada em forma de ruído, o que faz com que essa técnica seja menos comumente utilizada e não será abordada nesse tutorial.

Mapeamento inverso

O mapeamento inverso por outro lado se baseia nos pixels da nova imagem que será gerada a partir da transformação, ou seja, para cada coordenada de pixel na imagem f2 utilizamos a fórmula matemática para buscar o valor de intensidade desse pixel em f1. Com isso, todos pixels da nova imagem são considerado e não há perda de informação, sendo a técnica mais utilizada em implementações de bibliotecas.

Interpolação e espaço de cores

A interpolação é utilizada para se descobrir o valor de intensidade do pixels que se está transformando, onde o novo valor de intensidade é determinado baseado em uma vizinhança. Nesse tutorial, para fins de simplicidade não iremos utilizar as técnicas de interpolação, pois os exemplos são perfeitamente ilustrados apenas com o valor original do pixel da imagem de entrada, todavia, saiba que esta pode ser uma opção adicional ao criar transformações em suas imagens.

Além disso, iremos utilizar apenas imagens em escala de cinza para manipularmos um única matriz e facilitar a didática do funcionamento das fórmulas, muito embora os conceitos possam ser replicados para imagens em RGB com algumas adaptações.

Codificando

Nesse tutorial, irei utilizar a biblioteca SKImage para carregar algumas imagens de exemplo, porém, escolha imagens de sua preferência. Para realizar as operações matemáticas vamos utilizar o Numpy e o Math do Python bem como o matplotlib para visualização. Todas essas bibliotecas são bastante comuns e você não deve ter problemas em sua instalação caso necessário.

import numpy as np
import matplotlib.pyplot as plt
from skimage import data
import math
image = data.coffee()
plt.imshow(image, cmap='gray')
Fonte: https://scikit-image.org/docs/dev/api/skimage.data.html#skimage.data.coffee

Utilizaremos essa xícara de café como exemplo para as transformações.

Transformação para escala de cinza

O primeiro passo, conforme já explicado, é a transformação da imagem para uma escala de cinza. Em teoria, para realizar essa transformação basta fazer a média de intensidade de cada um dos pixels, conforme o código abaixo:

image_r = image[:,:,0]
image_g = image[:,:,1]
image_b = image[:,:,2]
# o método ceil do numpy impede que os valores sejam contínuos na imagem.
gray_image = np.ceil((image_r + image_g + image_b) / 3)
plt.imshow(gray_image, cmap='gray')

Primeiro, separamos os canais de cor R, G e B da imagem, sendo cada um deles uma matriz com as mesmas dimensões da imagem porém com valores de intensidade do pixel diferentes, respondendo ao estímulo eletromagnético que cada cor gera no sensor responsável pela captura da imagem. Feito isso, basta somar e dividir pelo número de canais, extraindo assim a média dos valores.

Abaixo podemos ver o resultado do código anterior:

Resultado da média dos valores de RGB.

É perceptível que o resultado está longe do esperado, embora a imagem esteja de fato em escala de cinza, possuímos distorções que degradam a nitidez da mesma. Isso acontece pelo fato de que nós percebemos essas três cores do espectro visível em diferentes níveis [1], portanto o uso de uma média não é eficiente para essa conversão.

Para conversão em escala de cinza de forma mais eficiente foi criado o seguinte método:

def convert_to_gray(image, luma=False):
if luma:
params = [0.299, 0.589, 0.114]
else:
params = [0.2125, 0.7154, 0.0721]
gray_image = np.ceil(np.dot(image[...,:3], params))

# Saturando os valores em 255
gray_image[gray_image > 255] = 255

return gray_image

Dessa forma, multiplicamos e somamos com o método dot do numpy cada camada da imagem RGB por um conjunto de pesos metodologicamente pré-definido, baseado na hipótese de percepção de cores dita anteriormente. Um esquema de parâmetros bastante utilizado é o Luma.

Por fim, também saturamos os valores em 255 para qualquer pixel cujo valor ultrapasse esse limite.

Imagem convertida com o novo método

Como podemos ver, agora possuímos a imagem em escala de cinza sem perder a nitidez.

Implementando as transformações geométricas

Todas as transformações seguem a mesma lógica de exploração espacial, portanto iremos implementar uma função genérica chamada geometric_transformation que será utilizada como base, alterando apenas a fórmula matemática que é passada como parâmetro, o que faz total diferença no resultado final da imagem.

def geometric_transformation(image, transform_f):
new_image = np.zeros((image.shape[0], image.shape[1]))

for row in range(image.shape[0]):
for column in range(image.shape[1]):
x,y = transform_f(row, column)

try:
if int(x) < 0 or int(y) < 0:
raise Exception
new_image[row][column] = image[int(x)][int(y)]
except:
new_image[row][column] = 0
return new_image

O primeiro passo da função, assim como já foi dito, é criar uma nova imagem que será o resultado final, uma vez que será aplicado o mapeamento inverso. Essa imagem inicialmente começa com o valor 0 de intensidade em todas as posições.

Feito isso, utilizamos dois laços de repetição for para percorrer cada uma das linhas e colunas da imagem. Você pode optar por fazer isso de outra forma, contanto que consiga percorrer a coordenada de cada um dos pixels da imagem.

A ideia é que para cada iteração tenhamos uma coordenada espacial diferente da matriz, por exemplo: (0,0) para primeira linha e primeira coluna, (0,1) para primeira linha e segunda coluna e assim por diante.

Feito isso, passamos as coordenadas para uma função de transformação a qual será responsável por aplicar as operações matemáticas e retornar os novos valores de coordenada X,Y.

Por fim, passamos por um bloco de try/except que será responsável por definir o valor de intensidade do pixel da coordenada respectiva. Por exemplo, o pixel da coordenada (0,0) na nova imagem vai receber o valor de intensidade do pixel na coordenada (1,2) da imagem original.

Existem duas exceções que podem acontecer nessa etapa:

  • Pixels caindo fora dos limites da imagem
  • Valores negativos

Pixels caindo fora dos limites da imagem

Suponha que nossa imagem original e a nova imagem transformada possuam 300x300 pixels. O que pode acontecer é que, digamos que na coordenada (290,290) da nova imagem o cálculo da função de transformação retorne as coordenada (298, 303) na imagem original, o que foge dos limites da mesma. Nesse caso, é possível tentar aumentar a dimensionalidade da imagem original, utilizar alguma técnica de interpolação por vizinhaça ou simplesmente passar o valor 0 (pixel preto) que foi a estratégia escolhida na implementação mostrada. Não existe respostas única e a melhor estratégia vai depender exclusivamente do seu objetivo.

Valores negativos

Pode acontecer também que o resultado de algumas fórmulas matemáticas gere uma coordenada negativa, por exemplo, (-2, 4). Nesses casos, ao indicar um valor negativo como posição de uma matriz no Python, as coordenadas serão invertidas entre o começo e o fim da matriz. Por exemplo, indicar a posição -2 seria o mesmo que indicar a posição 298 de uma imagem 300x300, o que pode mudar completamente o efeito, gerando uma duplicação da figura. Para resolver esse problema, foi implementado uma verificação simples que atribui o valor 0 para qualquer pixel com coordenadas negativas.

Agora que já explicamos as bases do processo podemos ir direto para as operações.

Translação

O efeito de translação pode ser entendido como o ato de “empurrar” ou deslocar a imagem pelo eixo X e/ou Y. Sua fórmula é definida por:

x’ = x + Tx

y’ = y + Ty

Onde x’ e y’ são as novas coordenadas da imagem que está sendo gerada, x e y são as coordenadas originais e Tx e Ty são parâmetros que vão indicar o quanto a imagem será empurrada em relação ao eixo X e Y. As fórmulas podem ser vistas aqui [1], todavia existem variações com o sinal de subtração (-) ao invés da soma (+), alterando apenas a origem da transformação geométrica na imagem.

def translate_image(row, column, dx=52.5, dy=32.3):
x = row - dx
y = column - dy
return x,y

O código a seguir é a implementação da fórmula e gera o seguinte resultado:

Imagem com translação utilizando o sinal de soma (+) e subtração (-).

O possível notar que os pontos que caem fora das coordenadas na imagem são substituídos por pixels pretos (0). Além disso, a imagem se deslocou mais pelo eixo X do que pelo Y, devido ao valor dos parâmetros escolhidos.

Escala

A operação de escala é bastante comum e pode ser intendida como um zoom in ou zoom out na imagem, alterando a escala da figura.

Sua fórmula é definida por:

x’ = Cx * x

y’ = Cy * y

Onde x’ e y’ são as novas coordenadas da imagem que está sendo gerada, x e y são as coordenadas originais e Cx e Cy são parâmetros que vão indicar a proporção do escalonamento que será realizado na imagem.

Assim como anteriormente, a fórmula presente em [1] pode ser encontrada também como uma divisão.

def scale_image(row, column, scale_factor=0.5):
x = row * scale_factor
y = column * scale_factor
return x,y
Escalonamento na imagem, um efeito parecido com um zoom é produzido

Na implementação do escalonamento da imagem utilizei apenas um parâmetro ao invés de dois, como na fórmula apresentada, para garantir que não sejam gerados distorções na figura, porém é possível implementar o segundo parâmetro facilmente caso seja necessário.

Rotação

A operação de rotação consiste em girar a figura presente na imagem por um determinado número de graus, fazendo com que os pixels sejam mapeados para uma nova coordenada sem gerar distorções.

Sua implementação é dada por:

x’ = x * cos(θ)- y * sen(θ)

y’ = x * sen(θ) + y * cos(θ)

Onde x’ e y’ são as novas coordenadas da imagem que está sendo gerada, x e y são as coordenadas originais e Sen e Cos são os valores de seno e cosseno do ângulo (θ) escolhido.

def rotate_image(row, column, angle=0.2):
x = row * math.cos(angle) - column * math.sin(angle)
y = row * math.sin(angle) + column * math.cos(angle)
return x,y
Rotacionamento a imagem em um ângulo de 0.2.

Cisalhamento

O cisalhamento consiste em aplicar uma “força” sobre os cantos da imagem, alterando sua forma para algo parecido com um paralelogramo. Sua fórmula pode ser definida por:

x’ = x + (y * Sx)

y’ = (x * Sy) + y

Onde x’ e y’ são as novas coordenadas da imagem que está sendo gerada, x e y são as coordenadas originais e Sy e Sx são os parâmetros que irão controlar o quanto do efeito do cisalhamento será aplicado em cada eixo. Esta fórmula é definida em [1] de forma separada, apenas para o eixo X ou apenas para o eixo Y, todavia podemos definir ela de maneira unificada, como foi mostrado, para que seja possível controlar a aplicação dessa transformação de forma simultânea em ambos os eixos.

def shear_image(row, column, shear_v=0.3, shear_h=0):
x = row + (column * shear_v)
y = (row * shear_h) + column
return x,y
Efeito de cisalhamento aplicado ao eixo X.

Conclusão

Futuramente pretendo trazer algumas outras técnicas como a transformação de intensidade e equilização de histograma, porém acredito que esse tenha sido um passo inicial importante para entender um pouco mais o funcionamento matemático das operações de processamento digital de imagens.

Referências

[1] http://poynton.ca/PDFs/ColorFAQ.pdf

[2] GONZALEZ, Rafael C.; WOODS, Richard C. Processamento digital de imagens . Pearson Educación, 2009.

--

--

Alvaro Leandro Cavalcante Carneiro
Data Hackers

MSc. Computer Science | Data Engineer. I write about artificial intelligence, deep learning and programming.