5.17. Um catálogo de kernels padrão

O processamento clássico de imagens acumulou um catálogo razoável de padrões de pesos de kernel que aparecem repetidamente – detectores de borda, realçadores de nitidez, embossings, suavizadores, motion blurs – e todos eles passam por morph(). Cada um é curto, cada um faz uma única coisa, e a maioria é fácil de ler depois que a lógica básica dos pesos faz sentido.

Todos os kernels abaixo são 3 por 3, salvo indicação em contrário, então todos usam size=1 na chamada. A estrutura de pesos de cada kernel é descrita ao lado dele, já que ler os pesos é o que constrói a intuição de por que um kernel produz emboss e outro produz nitidez.

5.17.1. O kernel identidade

O kernel mais simples possível é a identidade – um no centro e zero em todo o resto:

identity = [0, 0, 0,
            0, 1, 0,
            0, 0, 0]

img.morph(1, identity)

Cada pixel de saída recebe seu valor do centro da vizinhança, que é o pixel de entrada na mesma posição. A imagem passa sem alterações. A identidade não tem uso prático como filtro, mas é a base útil para entender todos os outros kernels: qualquer kernel não-identidade é a identidade mais alguma modificação.

Um kernel cujo peso central é grande com pequenos pesos negativos ao redor subtrai o entorno do centro. Um kernel com peso central zero ignora o próprio pixel e responde apenas às diferenças entre seus vizinhos. Ler um kernel dessa forma – o que o peso central faz com o pixel, o que os pesos do entorno adicionam ou removem – é a maneira mais rápida de prever seu efeito.

5.17.2. Detecção de bordas

Os kernels de detecção de bordas respondem fortemente a posições onde o brilho está mudando rapidamente em uma direção específica, e produzem saída próxima de zero onde o brilho é uniforme. São a família cujos pesos somam zero: uma área plana (todos os pixels com o mesmo valor) produz saída zero, porque cada peso positivo é cancelado exatamente por um peso negativo de igual magnitude.

Sobel-x é o exemplo canônico. Ele detecta bordas verticais (transições de brilho esquerda/direita):

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

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

O Sobel-y correspondente é o mesmo padrão rotacionado em 90 graus; ele detecta bordas horizontais (transições de brilho cima/baixo):

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

A linha do meio do Sobel-x tem pesos -2 e 2 em vez de -1 e 1. O peso extra na linha central confere ao kernel uma pequena suavização embutida na direção ao longo da borda, o que o torna mais robusto contra ruído do que o operador Prewitt, mais simples, que descarta essas magnitudes extras:

prewitt_x = [-1, 0, 1,
             -1, 0, 1,
             -1, 0, 1]

prewitt_y = [-1, -1, -1,
              0,  0,  0,
              1,  1,  1]

O Prewitt pondera todas as linhas igualmente, então sua resposta é um pouco mais nítida que a do Sobel, ao custo de ser mais sensível a ruído de pixel único (o custo de executar o kernel é idêntico – a convolução faz o mesmo trabalho independentemente dos pesos). Em uma imagem limpa com bordas fortes, é um substituto perfeitamente adequado para o Sobel.

O Scharr segue o caminho oposto. Seus pesos são maiores e ajustados para a detecção precisa da direção da borda em ângulos mais finos:

scharr_x = [-3,   0,  3,
            -10,  0, 10,
            -3,   0,  3]

img.morph(1, scharr_x, mul=0.0625, add=128)

O divisor mul=0.0625 (1/16) traz a saída de volta para dentro de 0255 após a soma de produtos maior. O Scharr é a resposta certa quando a aplicação precisa da resposta de gradiente geometricamente mais fiel e está disposta a pagar um pouco mais de aritmética por isso.

5.17.3. O Laplaciano

Um kernel Laplaciano responde a bordas em qualquer direção de uma só vez. Enquanto cada Sobel detecta mudanças de brilho ao longo de um eixo, o padrão de pesos simétrico do Laplaciano responde da mesma forma independentemente da direção em que a borda está indo:

laplacian_4 = [ 0, -1,  0,
               -1,  4, -1,
                0, -1,  0]

img.morph(1, laplacian_4, add=128)

A estrutura: peso central 4, quatro vizinhos horizontais/verticais com peso -1, e as quatro diagonais com peso zero. O kernel soma zero, então áreas planas produzem saída zero. Onde o brilho está mudando, o valor central difere da média de seus quatro vizinhos cardeais, e a saída é o tamanho dessa diferença.

A variante 8-conectada inclui os vizinhos diagonais:

laplacian_8 = [-1, -1, -1,
               -1,  8, -1,
               -1, -1, -1]

Cada kernel detecta coisas ligeiramente diferentes. A versão 4-conectada produz saída mais limpa em bordas horizontais e verticais; a 8-conectada é mais isotrópica – responde igualmente bem em todas as direções – mas produz saída um pouco mais ruidosa. O kernel 8-conectado também circula sob o nome outline, devido ao seu uso para visualizar bordas.

5.17.4. Realce de nitidez

Um kernel de realce de nitidez é a identidade mais um kernel de resposta de borda. A saída é a imagem original mais uma cópia das bordas, de modo que características de alta frequência são amplificadas em relação aos interiores suaves.

O kernel padrão de realce de nitidez 4-conectado adiciona o Laplaciano 4-conectado à identidade:

sharpen = [ 0, -1,  0,
           -1,  5, -1,
            0, -1,  0]

img.morph(1, sharpen)

Lendo o kernel: o peso central é identity (1) + Laplacian centre (4) = 5, e os entornos correspondem aos do Laplaciano. Áreas planas produzem 5 * 1 - 4 * 1 = 1 vezes o valor central – a identidade. As bordas produzem o original mais a resposta do Laplaciano. A soma dos pesos é 1, então mul e add permanecem em seus valores padrão.

Para um realce de nitidez mais forte, a variante 8-conectada vai além:

sharpen_strong = [-1, -1, -1,
                  -1,  9, -1,
                  -1, -1, -1]

img.morph(1, sharpen_strong)

O peso central 9 é identity (1) + Laplacian-8 centre (8). A mesma lógica, mais amplificação, mais risco de também amplificar o ruído do sensor.

Kernels de realce de nitidez fortes são essencialmente gaussian() com unsharp=True, apenas expressos diretamente como um kernel em vez de através do flag de máscara de nitidez (unsharp mask). O comportamento no nível do pixel é o mesmo; a escolha é entre a conveniência do método nomeado e o controle fino de um kernel ajustado à mão.

5.17.5. Emboss

Um kernel de emboss produz o efeito de iluminação lateral encontrado nos editores de imagem clássicos. A saída parece que a imagem foi extrudada em relevo e depois iluminada a partir de um canto:

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

img.morph(1, emboss, add=128)

O truque é a assimetria ao longo da diagonal. O canto superior esquerdo tem o peso mais negativo, o inferior direito tem o peso mais positivo, e a diagonal de canto a canto vai do negativo, passando por um, até o positivo. Em cada pixel o kernel essencialmente calcula “brilho à minha direita-inferior menos brilho à minha esquerda-superior”, que é positivo onde a imagem fica mais clara nessa direção e negativo onde fica mais escura. Adicionar 128 recentraliza a saída com sinal para o cinza médio, de modo que o efeito fique visível.

Rotacionar a assimetria ao longo da outra diagonal produz emboss a partir da direção oposta:

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

img.morph(1, emboss_alt, add=128)

As duas direções de emboss são úteis em combinação – subtraindo uma da outra, ou executando cada uma na mesma imagem e comparando as respostas – quando uma aplicação precisa detectar orientação.

5.17.6. Suavização

Os kernels de suavização são a família cujos pesos somam um (e são todos não-negativos). Uma área plana através de tal kernel produz o mesmo brilho plano, porque o kernel faz a média dos valores dos pixels em vez de amplificar suas diferenças.

O mais simples é o box blur, que é exatamente o que mean() calcula:

box_blur = [1, 1, 1,
            1, 1, 1,
            1, 1, 1]

img.morph(1, box_blur)

O kernel soma 9, então a divisão automática pela soma do kernel transforma a soma de produtos em uma verdadeira média sobre os nove pixels da vizinhança. Na prática, mean() é a melhor forma de executar este kernel – produz a mesma saída mais rápido, através de um caminho otimizado para calcular a média e nada mais, enquanto morph executa a maquinaria geral de convolução. O box blur está no catálogo porque é a base certa para entender todos os outros kernels de suavização.

Uma aproximação 3 por 3 da Gaussiana pondera o centro e os vizinhos cardeais mais do que os cantos:

gaussian = [1, 2, 1,
            2, 4, 2,
            1, 2, 1]

img.morph(1, gaussian)

Os pesos são a linha do triângulo de Pascal 1, 2, 1 em produto externo consigo mesma. O peso central 4 é o maior porque o pixel central contribui mais para sua própria saída; os cantos são 1 porque estão mais distantes do centro. O kernel soma 16, e a divisão automática pela soma do kernel cuida da normalização – nenhum argumento mul necessário. A forma 3 por 3 é uma aproximação grosseira de uma verdadeira Gaussiana e indistinguível de gaussian() em size=1; a forma morph é útil principalmente quando uma aplicação quer compor a suavização com outra operação na mesma passagem.

5.17.7. Motion blur

Um kernel de motion blur faz a média dos pixels ao longo de uma direção, deixando a direção perpendicular sem desfoque. O caso mais simples é horizontal:

motion_h = [0, 0, 0,
            1, 1, 1,
            0, 0, 0]

img.morph(1, motion_h)

A linha do meio faz a média de três pixels ao longo do eixo horizontal; as linhas superior e inferior são zero. O kernel soma 3, então a divisão automática pela soma do kernel produz uma verdadeira média de três pixels sem necessidade de qualquer mul. A saída é uma cópia da entrada borrada horizontalmente – o efeito que uma câmera captura quando o sujeito está se movendo lateralmente durante a exposição. O motion blur vertical é o mesmo padrão rotacionado:

motion_v = [0, 1, 0,
            0, 1, 0,
            0, 1, 0]

Um motion blur diagonal usa a diagonal principal:

motion_diag = [1, 0, 0,
               0, 1, 0,
               0, 0, 1]

img.morph(1, motion_diag)

Os kernels de motion blur são úteis tanto como um efeito (borrar deliberadamente um quadro para fins visuais) quanto como um padrão de teste para algoritmos que precisam ser robustos contra artefatos de movimento (executar o algoritmo em uma entrada com motion blur e verificar se ele ainda produz a resposta correta).

5.17.8. Lendo kernels num relance

Algumas regras práticas facilitam a leitura de novos kernels à primeira vista:

  • Soma um com pesos não-negativos ⇒ suavização (preserva o brilho médio).

  • Soma zero com pesos tanto positivos quanto negativos ⇒ resposta de borda (zero em áreas planas).

  • Soma um com um grande centro positivo e pequenos entornos negativos ⇒ realce de nitidez (identidade mais resposta de borda).

  • Assimétrico ao longo de uma diagonal com soma um ⇒ emboss (realça um lado de cada transição de brilho).

  • Concentrado ao longo de um eixo com soma um ⇒ desfoque direcional.

A primeira dessas com que o kernel corresponde costuma ser a melhor aposta sobre o que ele faz. A maioria dos kernels úteis é reconhecível apenas pelo layout de seu padrão de pesos.

Quando nenhum dos kernels padrão faz o que a aplicação quer, o próximo passo é ajustar um à mão. A combinação das regras acima com os controles mul / add cobre quase todas as passagens lineares que um pipeline clássico de visão de máquina já desejou; a partir daí é uma questão de testar pesos, observar a saída e iterar.