5.1. O objeto Image

Um algoritmo de processamento de imagem percorre uma imagem um pixel de cada vez. Em cada posição ele 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 quadro inteiro, essas decisões simples por pixel são aquilo a partir do qual a detecção de bordas, o rastreamento de blobs, a decodificação de QR-code e todas as demais técnicas clássicas de visão computacional são construídas. Para realizar esse trabalho de forma eficiente, o algoritmo precisa saber onde cada pixel está na memória, o que o valor de cada pixel de fato significa e qual porção da imagem ele deve estar observando. A image.Image é o objeto que organiza essas informações.

Vision Sensors terminou no momento em que csi.CSI.snapshot() retorna. O que quer que a maquinaria do lado da câmera tenha feito para produzir o quadro capturado já está concluído; a aplicação tem o Image em mãos e precisa saber o que fazer com ele.

5.1.1. O buffer e suas propriedades

Dentro do Image há um ponteiro para um bloco contíguo de bytes na RAM e um pequeno cabeçalho que carrega três informações de metadados: a largura da imagem em pixels, sua altura em pixels e o formato de pixel em que os bytes estão. Os bytes são os próprios pixels, armazenados em ordem por linhas (row-major) – primeiro todos os pixels da linha do topo, depois todos os da segunda linha, e assim por diante até o fundo. As propriedades descrevem como lê-los.

Largura e altura são simples contagens inteiras. O formato de pixel é a propriedade mais interessante, porque define quantos bytes cada pixel ocupa e o que esses bytes codificam. Uma imagem em escala de cinza carrega um byte por pixel contendo um valor de brilho. Uma imagem RGB565 carrega dois bytes por pixel contendo os campos de vermelho, verde e azul empacotados em uma palavra de 16 bits. Uma imagem Bayer carrega um byte por pixel, mas cada pixel é amostrado através de um de três filtros de cor escolhido por 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, toda outra propriedade que um algoritmo possa querer resulta de um cálculo curto. O byte que inicia o pixel (x, y) está 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 próxima linha abaixo está exatamente width * bytes_per_pixel bytes após o início da linha atual. A Image expõe as três propriedades por meio de simples chamadas de método – width(), height(), format() – além do size derivado, por meio de size(). Outros métodos no módulo usam esses valores para fazer a aritmética de deslocamento por conta própria; o código da aplicação raramente precisa fazê-lo.

Uma caixa rotulada image.Image -- wrapper Python no topo, com uma seta apontando para baixo rotulada "references" para duas caixas empilhadas -- uma caixa fina de cabeçalho contendo largura, altura e formato de pixel, e uma caixa mais grossa de buffer de pixels com uma fileira de pequenas células representando pixels individuais. Uma legenda abaixo observa que o buffer reside no heap por padrão e no frame buffer quando copy_to_fb é true.

Um Image é um pequeno wrapper Python que aponta para um bloco contíguo de memória: um cabeçalho que carrega a largura, a altura e o formato de pixel, seguido pelo próprio buffer de pixels.

5.1.2. De onde vem o buffer

A história padrão ao longo deste capítulo é a que Vision Sensors já cobriu: um quadro capturado chega de snapshot, os bytes estão no frame buffer da câmera, e o Image retornado aponta para eles. Três outras formas de obter um aparecem com regularidade, e cada uma implica algo diferente sobre onde o buffer acaba.

Carregar de um arquivo se parece com passar um caminho ao construtor: image.Image("/sdcard/saved.jpg"). O módulo lê o arquivo para um buffer recém-alocado no heap do Python. Arquivos BMP, PGM e PPM são decodificados durante a entrada e a Image resultante carrega um formato de pixel não comprimido. Arquivos JPEG e PNG permanecem comprimidos – a Image carrega o formato JPEG ou PNG, e o buffer mantém o fluxo de bytes do arquivo essencialmente inalterado. Para fazer qualquer trabalho a nível de pixel em uma imagem comprimida, a aplicação a converte primeiro por meio de to_rgb565() ou to_grayscale(), e essa conversão é onde a descompressão – e o correspondente inchaço do heap, em que um JPEG de 30 KB pode se tornar 600 KB de RGB565 – de fato acontece. Carregar de arquivo é mais útil durante o desenvolvimento, quando um algoritmo precisa ser testado contra um quadro de referência conhecido armazenado junto ao script.

Construir um do zero é o caso da tela em branco (canvas): image.Image(320, 240, image.RGB565) pede ao módulo para alocar essa quantidade de bytes nesse formato, zerar o conteúdo e devolver o wrapper. Os pixels ainda não significam nada – são todos zero – mas a imagem vazia é o cavalo de batalha para um punhado de padrões recorrentes: quadros de referência dos quais um quadro atual é subtraído, telas sobre as quais sobreposições gráficas são compostas, buffers binários que são preenchidos e usados como máscaras.

Construir a partir de um ndarray faz a ponte na direção oposta, de qualquer computação numérica de volta para o módulo image. Passar um ulab.numpy.ndarray float32 ao construtor produz um Image cujas dimensões correspondem ao ndarray – uma forma de dois eixos (h, w) torna-se uma imagem em escala de cinza, uma forma de três eixos (h, w, 3) torna-se RGB565 – com os valores float escalados de 0.0255.0 para a faixa inteira de pixels. Um mapa de calor de rede neural, 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 image pode usar.

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

5.1.3. Duas visões sobre os bytes

Na maior parte do tempo o código da aplicação trata um 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 do MicroPython que aceite um argumento bytes. Os bytes não são uma cópia do buffer; são uma visão direta dele.

Esse arranjo é o que torna o envio de um quadro capturado para fora da câmera uma única linha. Calcular seu hash, enviá-lo por uma porta serial, 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 visão tipo-bytes é somente leitura por padrão, de propósito. Buffers de imagem são grandes e às vezes compartilhados entre camadas da pilha de imageamento, então dar a um descuidado buf[0] = 0 em algum lugar profundo de uma pilha de chamadas o poder de corromper um silenciosamente é uma aresta afiada demais para deixar exposta. Quando o acesso a nível de byte com leitura e escrita é o que a aplicação de fato precisa – escrever um valor de calibração em um deslocamento conhecido, por exemplo – bytearray() retorna uma visão separada, explicitamente de leitura e escrita, sobre a mesma memória, sinalizando a intenção no ponto da chamada.

5.1.4. Onde o buffer reside

Buffers de pixels são grandes o suficiente para que o lugar onde ficam na RAM importe. Um quadro QQVGA RGB565 tem 160 × 120 × 2 = 38.400 bytes; um quadro VGA RGB565 tem 614.400 bytes; uma entrada RGB565 de 224 × 224 que um classificador de rede neural pode consumir tem cerca de 100 KB. O heap do Python nas câmeras menores pode ter apenas algumas dezenas de kilobytes depois que o runtime inicializa. Manter mais do que um quadro ou dois de dados de imagem no heap empurraria todo o resto para fora dele.

A saída é que os buffers de imagem em sua maioria não residem no heap do Python. Eles residem na região dedicada da RAM que Vision Sensors apresentou como o frame buffer – a mesma memória na qual o DMA da câmera escreve os quadros capturados e da qual a pré-visualização da IDE lê os quadros finalizados. A maioria das operações sobre um Image modifica sua fonte no lugar: o algoritmo lê pixels, decide, escreve novos valores de volta, e nenhuma imagem de resultado separada é alocada. As operações que de fato produzem um resultado separado – conversões de formato e algumas outras – podem ser instruídas a colocar esse resultado no frame buffer por meio do argumento de palavra-chave copy_to_fb. copy_to_fb=True faz duas coisas ao mesmo tempo: coloca a imagem de resultado no frame buffer em vez de no heap (contornando a pressão sobre o heap) e faz com que o resultado seja o próximo quadro que a pré-visualização da IDE exibirá. Acrescentar copy_to_fb=True ao passo final de um pipeline, observar o resultado aparecer na tela e iterar a partir daí é um dos idiomas de depuração mais úteis no processamento de imagem.

Com um wrapper contendo um buffer rotulado, quatro formas de trazer um à existência, duas visões sobre seus bytes e um interruptor que decide onde os novos vão parar, o Image deixa de ser um mistério. As questões fundamentais restantes – como uma posição de pixel é nomeada, o que cada pixel de fato contém, como restringir uma operação a uma porção de um – são construídas sobre ele.