5.1. O objeto Image

Um algoritmo de processamento de imagem percorre uma imagem pixel por pixel. Em cada posição, faz algo simples – lê um valor, compara-o com um limiar, combina-o com o pixel correspondente de uma segunda imagem, escreve um resultado de volta. Repetidas ao longo de um fotograma completo, essas decisões simples por pixel são a base sobre a qual se constroem a deteção de arestas, o rastreamento de manchas, a descodificação de códigos QR e todas as outras técnicas clássicas de visão por computador. Para realizar esse trabalho de forma eficiente, o algoritmo tem de saber onde cada pixel reside na memória, o que significa o valor de cada pixel, e qual a parte da imagem que deve observar. A image.Image é o objeto que organiza essa informação.

Os sensores de visão terminaram no momento em que csi.CSI.snapshot() retorna. Tudo o que a maquinaria do lado da câmara fez para produzir o fotograma capturado já está concluído; a aplicação tem a Image em mãos e precisa de saber o que fazer com ela.

5.1.1. O buffer e as suas propriedades

Dentro da Image existe um ponteiro para um bloco contíguo de bytes em RAM e um pequeno cabeçalho com três partes de metadados: a largura da imagem em pixels, a sua altura em pixels e o formato de pixel em que os bytes se encontram. Os bytes são os próprios pixels, armazenados em ordem de linha principal – todos os pixels da linha do topo primeiro, depois todos os da segunda linha, e assim sucessivamente até ao fundo. As propriedades descrevem como lê-los.

A largura e a altura são contagens inteiras simples. O formato de pixel é a propriedade mais interessante, pois define quantos bytes cada pixel ocupa e o que esses bytes codificam. Uma imagem em escala de cinzentos contém um byte por pixel com um valor de luminosidade. Uma imagem RGB565 contém dois bytes por pixel com os campos de vermelho, verde e azul compactados numa palavra de 16 bits. Uma imagem Bayer contém um byte por pixel, mas cada pixel é amostrado através de um de três filtros de cor escolhido pela sua posição no mosaico. Vision Sensors enumerou o catálogo completo; o que importa aqui é que exatamente um desses formatos está definido em cada Image, e essa escolha determina a aritmética de bytes por pixel e o significado de qualquer byte individual no buffer.

Com um ponteiro para o buffer, a largura, a altura e o formato, todas as outras propriedades que um algoritmo possa precisar resultam de um cálculo simples. O byte que inicia o pixel (x, y) encontra-se no deslocamento (y * width + x) * bytes_per_pixel a partir do início do buffer. A contagem total de bytes é width * height * bytes_per_pixel. O endereço da linha seguinte está exatamente width * bytes_per_pixel bytes após o início da linha atual. A Image expõe as três propriedades através de chamadas de método simples – width(), height(), format() – e o size derivado através de size(). Os métodos no restante do módulo utilizam esses valores para realizar a aritmética de deslocamentos internamente; o código da aplicação raramente precisa de o fazer.

A box labelled image.Image -- Python wrapper at the top, with an arrow pointing down labelled "references" to two stacked boxes -- a thin header box holding width, height, and pixel format, and a thicker pixel buffer box with a row of small cells representing individual pixels. A caption below notes that the buffer lives on the heap by default and in the frame buffer when copy_to_fb is true.

Uma Image é um pequeno invólucro Python que aponta para um bloco contíguo de memória: um cabeçalho com a largura, a altura e o formato de pixel, seguido do próprio buffer de pixel.

5.1.2. De onde vem o buffer

A história padrão ao longo deste capítulo é a que os sensores de visão já cobriram: um fotograma capturado chega a partir de snapshot, os bytes estão no buffer de fotograma da câmara e a Image retornada aponta para eles. Há três outras formas de obter uma que surgem regularmente, e cada uma implica algo diferente sobre onde o buffer acaba.

Carregar a partir de um ficheiro parece passar um caminho ao construtor: image.Image("/sdcard/saved.jpg"). O módulo lê o ficheiro para um buffer recém-alocado na heap do Python. Ficheiros BMP, PGM e PPM são descodificados durante a leitura e a Image resultante tem um formato de pixel não comprimido. Ficheiros JPEG e PNG permanecem comprimidos – a Image tem o formato JPEG ou PNG, e o buffer contém o fluxo de bytes do ficheiro essencialmente inalterado. Para realizar qualquer trabalho ao nível de pixel numa imagem comprimida, a aplicação converte-a primeiro através de to_rgb565() ou to_grayscale(), e é nessa conversão que ocorre a descompressão – e o correspondente aumento de memória na heap, onde um JPEG de 30 KB pode tornar-se 600 KB de RGB565. Carregar a partir de ficheiro é mais útil durante o desenvolvimento, quando um algoritmo precisa de ser testado com um fotograma de referência conhecido armazenado junto ao script.

Construir uma do zero é o caso da tela em branco: image.Image(320, 240, image.RGB565) pede ao módulo para alocar esse número de bytes nesse formato, zerar o conteúdo e devolver o invólucro. Os pixels ainda não significam nada – são todos zero – mas a imagem vazia é a ferramenta principal para alguns padrões recorrentes: fotogramas de referência dos quais é subtraído um fotograma atual, telas nas quais são compostas sobreposições gráficas, buffers binários preenchidos e usados como máscaras.

Construir a partir de um ndarray faz a ponte na outra direção, de qualquer computação numérica de volta ao módulo de imagem. Passar um ulab.numpy.ndarray do tipo float32 ao construtor produz uma Image cujas dimensões correspondem ao ndarray – uma forma de dois eixos (h, w) torna-se uma imagem em escala de cinzentos, uma forma de três eixos (h, w, 3) torna-se RGB565 – com os valores float escalados de 0.0255.0 para o intervalo inteiro de pixel. Um mapa de calor de rede neuronal, um array numérico de qualquer tipo, qualquer coisa produzida por ml ou ulab torna-se algo que o lado de desenho e inspeção do módulo de imagem pode usar.

Todas as quatro fontes devolvem o mesmo tipo de Image. O código que usa o objeto devolvido nunca precisa de rastrear de onde ele veio.

5.1.3. Duas vistas sobre os bytes

Na maior parte do tempo, o código da aplicação trata uma Image como um objeto de imagem tipado – algo com métodos nomeados. A outra metade da história é que o mesmo objeto também aparece, de forma transparente, como uma sequência plana de bytes para qualquer API MicroPython que aceite um argumento bytes. Os bytes não são uma cópia do buffer; são uma vista direta do mesmo.

Esse arranjo é o que torna possível enviar um fotograma capturado para fora da câmara com uma única linha. Calcular o seu hash, enviá-lo por uma porta série, encaminhá-lo para um socket de rede – nenhuma dessas operações precisa de um passo separado de «converter a imagem em bytes»:

import csi
import hashlib

csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QQVGA)

img = csi0.snapshot()
uart.write(img)              # transmits the raw pixel bytes
hashlib.sha256(img)          # hashes the same bytes
sock.send(img)               # sends them over a socket

A vista tipo bytes é só de leitura por defeito, intencionalmente. Os buffers de imagem são grandes e por vezes partilhados entre camadas da pilha de imagem, por isso dar a um buf[0] = 0 algures no fundo de uma pilha de chamadas o poder de corromper silenciosamente um deles é uma aresta demasiado afiada para deixar exposta. Quando o que a aplicação realmente precisa é de acesso de leitura e escrita ao nível de bytes – escrever um valor de calibração num deslocamento conhecido, por exemplo – bytearray() devolve uma vista separada e explicitamente de leitura e escrita sobre a mesma memória, sinalizando a intenção no ponto de chamada.

5.1.4. Onde reside o buffer

Os buffers de pixel são suficientemente grandes para que a sua localização em RAM seja importante. Um fotograma QQVGA RGB565 tem 160 × 120 × 2 = 38.400 bytes; um fotograma VGA RGB565 tem 614.400 bytes; uma entrada 224 × 224 RGB565 que um classificador de rede neuronal possa consumir tem cerca de 100 KB. A heap do Python nas câmaras mais pequenas pode ter apenas algumas dezenas de kilobytes depois de o runtime ter arrancado. Manter mais do que um ou dois fotogramas de dados de imagem na heap sobrecarregaria todo o resto.

A solução é que os buffers de imagem maioritariamente não residem na heap do Python. Residem na região dedicada de RAM que os Vision Sensors introduziram como o buffer de fotograma – a mesma memória para a qual o DMA da câmara escreve os fotogramas capturados e da qual a pré-visualização do IDE lê os fotogramas concluídos. A maioria das operações sobre uma Image modifica a sua fonte no lugar: o algoritmo lê os pixels, decide e escreve novos valores de volta, sem alocar uma imagem de resultado separada. As operações que produzem um resultado separado – conversões de formato e algumas outras – podem ser instruídas a colocar esse resultado no buffer de fotograma através do argumento de palavra-chave copy_to_fb. copy_to_fb=True faz duas coisas ao mesmo tempo: coloca a imagem resultante no buffer de fotograma em vez de na heap (contornando a pressão na heap) e torna o resultado o próximo fotograma que a pré-visualização do IDE irá mostrar. Adicionar copy_to_fb=True ao passo final de um pipeline, observar o resultado aparecer no ecrã e iterar a partir daí é um dos idiomas de depuração mais úteis no processamento de imagem.

Com um invólucro a conter um buffer etiquetado, quatro formas de o colocar em existência, duas vistas sobre os seus bytes e um interruptor a decidir onde os novos aterram, a Image deixa de ser um mistério. As restantes questões fundamentais – como se nomeia uma posição de pixel, o que contém realmente cada pixel, como limitar uma operação a uma parte de uma – são construídas sobre ela.