5.16. Kernels de convolução personalizados

Os filtros de vizinhança abordados até aqui possuíam, cada um, uma estatística embutida que o filtro aplicava à janela em cada posição – a média, a média ponderada gaussiana, a mediana. morph() é o único filtro que permite que a aplicação forneça a própria estatística, na forma de um kernel: uma pequena matriz de pesos que descreve como o filtro deve combinar os pixels da vizinhança em um único valor de saída.

O mecanismo é a clássica operação de convolução. Em cada posição de saída, cada pixel da vizinhança é multiplicado pelo peso correspondente no kernel, os produtos são somados, o resultado é opcionalmente escalonado e deslocado, e o valor é escrito no pixel de saída. Kernels diferentes produzem resultados diferentes a partir da mesma entrada. Um kernel com todos os pesos positivos iguais reproduz o filtro mean(); um em forma de sino reproduz gaussian(). Padrões além desses produzem respostas de borda, relevos, gradientes, nitidez, desfoque de movimento e um longo catálogo de outros efeitos – tudo o que o processamento clássico de imagens já quis fazer com uma única passagem linear.

5.16.1. O método morph

A assinatura se parece com a dos outros filtros de vizinhança, com um argumento extra:

img.morph(size, kernel, mul=1.0, add=0.0)

size é o raio, da mesma forma que em todos os outros lugares, portanto o kernel deve ter exatamente (2 * size + 1) linhas por (2 * size + 1) colunas. O kernel em si é uma lista Python plana com essa quantidade de números, em ordem row-major (por linha) – as primeiras (2 * size + 1) entradas são a linha superior, as próximas (2 * size + 1) são a segunda linha, e assim por diante, até a linha inferior. mul escalona a soma dos produtos antes de ela ser escrita no pixel de saída, e add adiciona uma constante. Os valores padrão mul=1.0 e add=0.0 deixam a saída da convolução inalterada.

Um detalhe que vale deixar explícito: o método divide automaticamente a soma dos produtos pela soma das entradas do kernel antes de escrever a saída. Essa divisão automática significa que um kernel de média cujas entradas somam nove – um box blur 3 por 3, por exemplo – sai em escala de um nono sem nenhum esforço extra, e um kernel de aproximação gaussiana que soma dezesseis sai em escala de um dezesseis avos, ambos sem que a aplicação precise calcular a divisão por conta própria. A aplicação define mul apenas quando deseja uma escala adicional sobre a normalização automática – ou, mais comumente, quando o kernel soma zero (um kernel de resposta de borda) e a divisão automática seria uma divisão por nada. Nesse caso, o framework trata a soma como um, e mul torna-se o único controle para manter a soma dos produtos não escalonada dentro da faixa.

O par threshold=True / offset=N da seção de limiar adaptativo também funciona em morph(), de modo que o mesmo framework de kernel personalizado pode produzir um limiar binário cujo corte é calculado por uma estatística personalizada.

5.16.2. O layout do kernel

Um kernel 3 por 3 (size=1) é uma lista plana de nove números dispostos da esquerda para a direita, de cima para baixo. A convenção fica natural de ler se a lista for quebrada em três linhas Python:

sobel_x = [-1,  0,  1,
           -2,  0,  2,
           -1,  0,  1]

Este é o operador de gradiente Sobel-x – o primeiro kernel padrão que qualquer aplicação vai querer e um bom exemplo para percorrer de ponta a ponta. O padrão é simples: pesos negativos na coluna da esquerda, pesos positivos na coluna da direita, com a coluna central zerada. Os pesos das linhas -1, -2, -1 (ou 1, 2, 1 à direita) são maiores no meio do que nos cantos, o que dá à linha central mais influência sobre o resultado do que às linhas dos cantos.

Quando o kernel varre uma borda vertical – uma coluna de pixels que vai de escuro à esquerda para claro à direita – os pesos negativos captam o lado escuro e os pesos positivos captam o lado claro. A soma dos produtos é um número positivo grande, que o filtro escreve como um pixel de saída claro. Uma área horizontal de brilho uniforme produz zero, porque cada peso positivo é compensado por um peso negativo de mesma magnitude sobre um pixel com o mesmo valor.

Executando o kernel:

img.morph(1, sobel_x, mul=0.25)

O kernel Sobel soma zero – cada peso negativo do lado esquerdo é compensado por um peso positivo igual à direita – portanto a divisão automática não divide por nada, e mul é a única escala sobre a soma dos produtos. mul=0.25 mantém a resposta dentro da faixa: a maior soma absoluta que o Sobel-x pode produzir a partir de uma área 3 por 3 é de aproximadamente 4 * 255 = 1020 (oito pixels claros ponderados até 2), e dividir isso por quatro coloca os casos extremos em 255, onde o formato os recorta de forma limpa.

O kernel Sobel-y correspondente detecta bordas horizontais girando o mesmo padrão de pesos em 90 graus:

sobel_y = [-1, -2, -1,
            0,  0,  0,
            1,  2,  1]

Aplicações que desejam detectar qualquer borda, independentemente da direção, normalmente executam ambos os Sobels e combinam as respostas.

5.16.3. Deslocando a saída

add é a outra metade da história do escalonamento. A resposta de um kernel de soma zero é com sinal – positiva de um lado da borda, negativa do outro – e a metade negativa é recortada para zero ao ser escrita em um pixel sem sinal. add=128 desloca a resposta para ficar centrada no cinza médio, de modo que respostas negativas sobrevivem como valores abaixo de 128 e as positivas ficam acima dele: uma resposta de borda ou um relevo torna-se visível em ambas as direções, ao custo de metade da faixa em cada uma.

Qual combinação de mul e add um kernel espera faz parte do projeto do kernel; o catálogo de kernels padrão lista as configurações corretas para cada kernel comum.

5.16.4. Kernels maiores

Tudo nesta página foi descrito com kernels 3 por 3 (size=1), porque esse é o tamanho usado pelo catálogo padrão e porque o layout row-major é fácil de escrever à mão nesse tamanho. Nada no mecanismo, porém, restringe o kernel a 3 por 3. size=2 executa um kernel 5 por 5, com vinte e cinco entradas na lista plana; size=3 executa um 7 por 7 com quarenta e nove; e assim por diante, até o raio que a aplicação estiver disposta a pagar. O framework lida com layouts de lista plana ou de linhas aninhadas em qualquer tamanho ímpar.

A razão para recorrer a um kernel maior é a mesma para recorrer a uma vizinhança maior em qualquer um dos filtros embutidos: mais média, detecção de características mais ampla, menos sensibilidade a ruído de pixel único. O custo cresce com o quadrado do raio – um 5 por 5 faz cerca de 2,8 vezes o trabalho por pixel de um 3 por 3, um 7 por 7 cerca de 5,4 vezes – e esse multiplicador sai diretamente da taxa de quadros.

O padrão prático é permanecer em size=1 para o catálogo padrão e recorrer a tamanhos maiores apenas quando o algoritmo precisar da vizinhança maior. Detectores de borda raramente se beneficiam além de 3 por 3; filtros de suavização às vezes se beneficiam; o tamanho certo depende da escala das características que a aplicação está tentando enfatizar ou suprimir.

5.16.5. Quando recorrer ao morph

Para suavização do dia a dia, mean(), gaussian() e bilateral() são mais rápidos e mais limpos. Para detecção de bordas, laplacian() e find_edges() são feitos sob medida. O argumento para recorrer diretamente a morph() é quando a aplicação precisa de uma convolução específica que os filtros embutidos não expõem – um Sobel direcional, um modelo de borda personalizado, um kernel ajustado a uma textura específica que o resto do pipeline vai procurar, ou qualquer um dos kernels úteis do catálogo padrão que o processamento clássico de imagens acumulou ao longo das décadas. Toda a flexibilidade de kernels arbitrários está disponível; o preço é que a aplicação é responsável por escolher os valores do kernel que produzem o resultado desejado.