14. Tipo NPImagem

Vimos que o tipo ndarray do módulo NumPy é muito utilizado em computação científica. Nessa aula vamos continuar a explorar essa estrutura para treinar o uso de fatias e operações com arrays sem o uso de laços.

14.1. Objetivos de aprendizagem

Realize seus estudos para que, ao final dessa aula, você seja capaz de:

  • utilizar fatias de arrays para evitar o uso de laços;
  • explicar como utilizar ndarray para representar uma imagem;
  • criar a classe NPImagem baseada em ndarray;
  • usar objetos do tipo NPImagem em seus programas.

14.2. Introdução

Uma grande vantagem de usar arrays do NumPy é a facilidade de manipular essas estruturas n-dimensionais sem usar laços. Esse é mais um poderoso exemplo de abstração, que nos permite pensar na informação que precisamos processar sem se preocupar com a implementação interna.

Para realçar esse ponto, considere o seguinte trecho de código abaixo que cria um array \(4\times5\) e preenche uma região definida pelo canto superior-esquerdo (1,1) até o canto inferior-direito (3,4) com o valor -1.

Como fatias são vistas do array, podemos usar fatias de arrays para modificar regiões como:

import numpy as np

li = list(range(20))
ar = np.array(li).reshape(4,5)
print(ar)

# carrega a região definida pelo canto superior-esquerdo (1,1)
# e canto inferior-direito (4,4) com o valor -1
s, e = 1, 1
i, d = 3, 4
v = -1
for lin in range(s, i):
    ar[lin, e:d] = v  # fatia e:d evita o laço que varre as colunas

print(ar)

e mais ainda, podemos combinar as fatias para as linhas e colunas como:

import numpy as np

li = list(range(20))
ar = np.array(li).reshape(4,5)
print(ar)

# carrega a região definida pelo canto superior-esquerdo (1,1)
# e canto inferior-direito (4,4) com o valor -1
s, e = 1, 1
i, d = 3, 4
v = -1
# fatia [s:i,e:d] define um retângulo que recebe o valor v
ar[ s:i, e:d ] = v

print(ar)

Esse código, sem laços, tende a ser mais eficaz, fácil de escrever e, depois que você se acostumar com a notação, mais fácil de ler. Edite o código na janela do Trinket para verificar se os resultados são os mesmos.

A seguir vamos treinar o uso de fatias para evitar laços usando a classe NPImagem para trabalhar com imagens digitais.

14.3. Imagem Digital

Uma imagem digital ou simplesmente imagem é basicamente uma matriz (um objeto com duas dimensões), com, digamos, altura (= height = número de linhas) e largura (= width = número de colunas). Cada elemento da matriz é chamada de pixel (= picture element), que possui uma “cor”.

Na sua forma mais básica, a cor de um pixel pode ser representado por 1 bit (= simplificação de dígito binário, em inglês binary digit). O bit 1 indica que o pixel está aceso ou é “branco”. O bit 0 indica que o pixel está apagado ou é “preto”. Imagens binárias são aquelas em que cada pixel é branco ou preto.

Imagem "Neighborhood_watch_bw"

Fig. 14.1 Exemplo de imagem binária. Fonte: Binary image (Wikipedia).

No entanto, os valores, ou níveis, 0 e 1 são insuficientes para representarmos o que costumamos chamar de imagens em preto e branco, pois estas possuem vários níveis ou tons de cinza. Uma forma comum de representar uma imagem com vários tons de cinza é reservando um byte para cada pixel. Um byte consiste de 8 bits representando um valor entre 0 e 255, ou seja, com um byte podemos representar até 256 tons de cinza.

Exemplo de imagem em níveis de cinza

Fig. 14.2 Exemplo de imagem em níveis de cinza. Fonte: Grayscale image (Wikipedia).

Chamamos as imagens em que cada pixel pode ter vários tons de cinza de imagens com níveis de cinza (grayscale).

Uma imagem com níveis de cinza nos permite ver as variações de luminosidade da cena. Já uma imagem colorida requer ainda mais informação para cada pixel. Baseado no sentido da visão humana, que é tricromática, a representação de imagens mais comum é obtida decompondo uma cor nas componentes básicas vermelho (red), verde (green), e azul (blue) ou RGB. Assim, usando um total de três bytes: um byte para níveis de vermelho; um byte para níveis de verde e um byte para níveis de azul, podemos representar aproximadamente todo o espectro visível de cores.

Exemplo de imagem colorida

Fig. 14.3 Exemplo de imagem colorida. Fonte: Arara (Wikipedia).

14.4. A classe NPImagem

A classe NPImagem deve representar imagens digitais por meio de ndarrays. Para isso, um objeto da classe NPImagem deve possuir um atributo de nome data do tipo numpy.ndarray que é utilizado internamente nas operações com imagens dessa classe.

Seja img um objeto da classe NPImagem. Então img deve se comportar da seguinte forma:

  1. Criação usando img = NPImagem( (nlins, ncols), val). Um objeto NPImagem é criado ao escrevermos NPImagem( (nlins, ncols), val). Aqui nlins e ncols são dois inteiros que definem a dimensão \(nlins \times ncols\) do array que representa a imagem e val é o valor inicial de cada um de seus pixels. Caso o val não seja especificado na chamada, a imagem deve ser criada tendo 0 como valor de cada pixel.

Importante: caso o tipo do val seja um array do Numpy, a NPImagem criada deve usar esse array como seu conteúdo inicial. Veja nos exemplos que os valores de nlins e ncols não importam se val é um array.

  1. A função print() deve exibir um objeto NPImagem simplesmente mostrando o conteúdo do atributo data. Esse item é muito importante para ajudar você a depurar sua classe.
  2. img.shape. O valor do atributo shape de um objeto img da classe NPImagem deve ser a tupla (nlins, ncols) que indica a dimensão de img.
classe NPImagem:

    def __init__(self, shape=(0,0), val = 0):
        ''' Construtor da classe NPImagem
        '''
        if type(val) is np.ndarray:
            self.data = val  ## compartilha dados com val
        else:
            self.data = np.full( shape, val )
        self.shape = self.data.shape

    def __str__(self):
        return str(self.data)

14.5. Exercícios

Complete a classe NPImagem tal que:

  1. Valor de um pixel: implemente o método __getitem_() para permitir que, ao escrevermos img[lin, col], tenhamos o valor do pixel em [lin, col].

  2. Alterando o valor de um pixel: implemente o método __setitem__() para permitir que, ao escrevermos img[lin, col] = val, o valor val seja armazenado no pixel [lin, col].

  3. Método crop(): recebe 4 inteiros sup, esq, inf e dir que definem uma região retangular de img onde:
    • esq indica a primeira coluna,
    • dir a última coluna,
    • sup a primeira linha e
    • inf a última linha do retângulo.

    Você pode entender esses inteiros como as coordenadas do canto superior-esquerdo (sup,esq) e do canto inferior-direirto (inf,dir) do retângulo. Assim, se img é um objeto NPImagem, então img.crop(sup, esq, inf, dir) deve retornar uma nova NPImagem com a subimagem de img definida por sup, esq, inf e dir. Caso esses quatro valores não sejam especificados na chamada, o método considera sup e esq como sendo zero, a origem da imagem, e inf e dir como as dimensões da imagem. Nesse caso, portanto, a chamada retorna um clone de img. O exemplo também mostra quando apenas o canto (sup, esq) é fornecido.

Você pode estender sua classe com outros métodos, atributos e usar outras funções caso desejar, desde que eles não entrem em conflito ou modifiquem o comportamento da classe NPImagem, como especificado nesse texto.

14.5.1. DICAS

  • Não faça nada do zero. Utilize o que você já implementou nos EPs anteriores e apenas faça as modificações necessárias para criar a classe NPImagem.
  • Para fazer recortes com crop(), você pode utilizar fatias de arrays. Além de ser mais eficiente, você economiza tempo escrevendo menos código.

14.5.2. exemplos

Estude os exemplos a seguir para compreender o comportamento esperado de um objeto do tipo NPImagem.

lista = list(range(20))
ar = np.array(lista).reshape(4,5)
img1 = NPImagem( (0, 0), ar)  #
print(f"img1:\n{img1}")
print(f"Shape de img1: {img1.shape}\n")

img2 = NPImagem( (4, 3), 100)
print(f"img2:\n{img2}")
print(f"Shape de img2: {img2.shape}\n")

img2[1,2] = -10
print(f"img2[1,2]={img2[1,2]}")
print(f"img2:\n{img2}\n")

img3 = img2.crop() ## cria uma cópia
print(f"img3:\n{img3}\n")

img4 = img1.crop(0, 1, 3, 4)
print(f"img4:\n{img4}\n")

img5 = NPImagem( (3,2) )
print(f"img5:\n{img5}\n")

img6 = img1.crop(1,2)
print(f"img6:\n{img6}\n")

O resultado deve ser:

img1:
[[ 0  1  2  3  4]
 [ 5  6  7  8  9]
 [10 11 12 13 14]
 [15 16 17 18 19]]
Shape de img1: (4, 5)

img2:
[[100 100 100]
 [100 100 100]
 [100 100 100]
 [100 100 100]]
Shape de img2: (4, 3)

img2[1,2]=-10
img2:
[[100 100 100]
 [100 100 -10]
 [100 100 100]
 [100 100 100]]

img3:
[[100 100 100]
 [100 100 -10]
 [100 100 100]
 [100 100 100]]

img4:
[[ 1  2  3]
 [ 6  7  8]
 [11 12 13]]

img5:
[[0 0]
 [0 0]
 [0 0]]

img6:
[[ 7  8  9]
 [12 13 14]
 [17 18 19]]

14.6. Onde estamos e para onde vamos?

O uso de ndarrays para representar imagens digitais é o exemplo que escolhemos para realçar o uso de fatias de arrays. Acreditamos que o conceito de imagem seja simples de entender e divertido de manipular usando operações “globais”, ou seja, em toda a imagem. Vimos que, ao realizar as operações com arrays, ou fatias de arrays, podemos evitar o uso de laços encaixados, simplificando o código e permitindo que a gente resolva problemas pensando em imagens, ao invés de pensar nos pixels e nos laços para varrer as imagens.

Na próxima aula vamos continuar a estender a classe NPImagem para continuar a treinar o uso de arrays e a habilidade de abstração de dados.