5.16. Kernels de convolução personalizados

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

O mecanismo é a operação clássica de convolução. Em cada posição de saída, cada pixel vizinho é multiplicado pelo peso correspondente no kernel, os produtos são somados, o resultado é opcionalmente escalado e deslocado, e o valor é escrito no pixel de saída. Kernels diferentes produzem resultados diferentes a partir do mesmo input. 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 aresta, relevos, gradientes, nitidez, desfoque de movimento e um longo catálogo de outros efeitos – tudo o que o processamento clássico de imagem alguma vez quis fazer com uma única passagem linear.

5.16.1. O método morph

A assinatura é semelhante à 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 casos, pelo que o kernel deve ter exatamente (2 * size + 1) linhas por (2 * size + 1) colunas. O kernel em si é uma lista Python plana com esse número de elementos, em ordem row-major – as primeiras (2 * size + 1) entradas correspondem à linha superior, as seguintes (2 * size + 1) à segunda linha, e assim por diante até à linha inferior. mul escala a soma dos produtos antes de ser escrita no pixel de saída, e add adiciona uma constante. Os valores predefinidos mul=1.0 e add=0.0 deixam o output da convolução inalterado.

Um detalhe que vale a pena explicitar: o método divide automaticamente a soma dos produtos pela soma das entradas do kernel antes de escrever o output. Essa divisão automática significa que um kernel de média cujas entradas somam nove – um desfoque de caixa 3-por-3, por exemplo – sai à escala de um nono sem esforço adicional, e um kernel de aproximação Gaussiana que soma dezasseis sai à escala de um décimo sexto, ambos sem que a aplicação precise de calcular a divisão por si mesma. A aplicação define mul apenas quando quer um escalonamento adicional para além da auto-normalização – ou, mais frequentemente, quando o kernel soma zero (um kernel de resposta de aresta) e a auto-divisão seria uma divisão por nada. O framework trata a soma como um nesse caso, e mul torna-se o único ajuste para manter a soma dos produtos sem escalonamento dentro do intervalo.

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

5.16.2. O esquema 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 lê-se naturalmente se a lista for dividida 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 útil para percorrer do início ao fim. O padrão é simples: pesos negativos na coluna da esquerda, pesos positivos na coluna da direita, com a coluna central a zero. Os pesos de linha -1, -2, -1 (ou 1, 2, 1 na direita) são maiores no centro do que nos cantos, o que dá à linha central mais influência sobre o resultado do que as linhas dos cantos.

Quando o kernel percorre uma aresta vertical – uma coluna de pixels que vai de escuro à esquerda para brilhante à direita – os pesos negativos captam o lado escuro e os pesos positivos captam o lado brilhante. A soma dos produtos é um grande número positivo, que o filtro escreve como um pixel de saída brilhante. Um bloco horizontal de brilho uniforme produz zero, porque cada peso positivo é igualado por um peso negativo de igual magnitude num pixel com o mesmo valor.

Executar o kernel:

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

O kernel Sobel soma zero – cada peso negativo do lado esquerdo é igualado por um peso positivo igual do lado direito – pelo que a auto-divisão não divide por nada, e mul é o único escalonamento da soma dos produtos. mul=0.25 mantém a resposta dentro do intervalo: a maior soma absoluta que o Sobel-x pode produzir a partir de um bloco 3-por-3 é aproximadamente 4 * 255 = 1020 (oito pixels brilhantes com peso até 2), e dividir por quatro coloca os casos extremos em 255, onde o formato os corta de forma limpa.

O kernel Sobel-y correspondente deteta arestas horizontais rodando o mesmo padrão de pesos 90 graus:

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

As aplicações que pretendem detetar qualquer aresta, independentemente da direção, normalmente executam ambos os Sobels e combinam as respostas.

5.16.3. Deslocar o output

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

Qual a combinação de mul e add que um kernel espera faz parte do design do kernel; o catálogo de kernels padrão lista as definiçõ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 que o catálogo padrão utiliza e porque o esquema row-major é fácil de escrever manualmente nesse tamanho. Nada no mecanismo 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é ao raio que a aplicação estiver disposta a pagar. O framework suporta tanto listas planas como esquemas de linhas aninhadas em qualquer tamanho ímpar.

A razão para usar um kernel maior é a mesma que usar uma vizinhança maior em qualquer um dos filtros incorporados: mais média, deteção de características mais ampla, menor sensibilidade ao ruído de pixel único. O custo cresce com o quadrado do raio – um 5-por-5 faz aproximadamente 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 fotogramas.

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

5.16.5. Quando recorrer ao morph

Para suavização comum, mean(), gaussian() e bilateral() são mais rápidos e mais limpos. Para deteção de arestas, laplacian() e find_edges() são construídos de raiz para esse fim. O caso para recorrer diretamente a morph() é quando a aplicação necessita de uma convolução específica que os filtros incorporados não expõem – um Sobel direcional, um template de aresta personalizado, um kernel ajustado a uma textura particular que o resto do pipeline vai procurar, ou qualquer um dos kernels úteis do catálogo padrão que o processamento clássico de imagem acumulou ao longo das décadas. A flexibilidade total 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 pretendido.