5.17. Um catálogo de kernels standard¶
O processamento de imagem clássico acumulou um catálogo considerável de padrões de pesos de kernel que surgem repetidamente – detetores de arestas, nitidizadores, relevos, suavizadores, desfocagens de movimento – e cada um deles é executado através de morph(). Cada um é curto, faz uma coisa, e a maioria é fácil de ler assim que a lógica básica dos pesos fizer sentido.
Os kernels abaixo são todos 3-por-3, salvo indicação em contrário, portanto todos utilizam size=1 na chamada. A estrutura de pesos de cada kernel é descrita ao seu lado, uma vez que ler os pesos é o que constrói a intuição sobre porque é que um kernel produz relevo e outro nitidez.
5.17.1. O kernel de identidade¶
O kernel mais simples possível é o de identidade – um no centro, 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 o seu valor a partir 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 utilidade prática como filtro, mas é a referência útil para compreender todos os outros kernels: qualquer kernel não-identitário é a identidade com alguma modificação.
Um kernel cujo peso central é grande com pequenos pesos negativos à sua volta subtrai a vizinhança do centro. Um kernel com um peso central zero ignora o próprio pixel e responde apenas às diferenças entre os seus vizinhos. Ler um kernel desta forma – o que o peso central faz ao pixel, o que os pesos circundantes acrescentam ou retiram – é a forma mais rápida de prever o seu efeito.
5.17.2. Deteção de arestas¶
Os kernels de deteção de arestas respondem fortemente a posições onde o brilho muda rapidamente numa direção particular, e produzem saída próxima de zero onde o brilho é uniforme. São a família cujos pesos somam zero: um bloco plano (cada pixel com o mesmo valor) produz saída zero, porque cada peso positivo é exatamente cancelado por um peso negativo de igual magnitude.
Sobel-x é o exemplo canónico. Deteta arestas 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 rodado 90 graus; deteta arestas horizontais (transições de brilho cima/baixo):
sobel_y = [-1, -2, -1,
0, 0, 0,
1, 2, 1]
A fila central do Sobel-x tem pesos -2 e 2 em vez de -1 e 1. O peso extra na fila central dá ao kernel uma pequena suavização incorporada na direção ao longo da aresta, o que o torna mais robusto contra o ruído do que o operador Prewitt mais simples que elimina essas magnitudes extra:
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 cada fila igualmente, pelo que a sua resposta é ligeiramente mais nítida do que a do Sobel, ao custo de ser mais sensível ao ruído de pixel único (o custo de executar o kernel é idêntico – a convolução faz o mesmo trabalho independentemente dos pesos). Numa imagem limpa com arestas fortes, é um substituto perfeitamente adequado para o Sobel.
Scharr vai na direção oposta. Os seus pesos são maiores e ajustados para uma deteção precisa da direção das arestas 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 0 – 255 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 arestas em qualquer direção simultaneamente. Onde cada Sobel deteta 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 da aresta:
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, as quatro diagonais com peso zero. O kernel soma zero, pelo que blocos planos produzem saída zero. Onde o brilho muda, o valor central difere da média dos seus quatro vizinhos cardinais, e a saída é o tamanho dessa diferença.
A variante com 8 ligações inclui os vizinhos diagonais:
laplacian_8 = [-1, -1, -1,
-1, 8, -1,
-1, -1, -1]
Cada kernel deteta coisas ligeiramente diferentes. A versão com 4 ligações produz saída mais limpa em arestas horizontais e verticais; a com 8 ligações é mais isotrópica – responde igualmente bem em todas as direções – mas produz saída ligeiramente mais ruidosa. O kernel com 8 ligações também circula com o nome contorno, após o seu uso para visualizar arestas.
5.17.5. Relevo¶
Um kernel de relevo produz o efeito de iluminação lateral encontrado nos editores de imagem clássicos. A saída parece que a imagem foi extrudida num relevo e depois iluminada 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 canto inferior direito tem o peso mais positivo, e a diagonal de canto a canto vai do negativo através de um até ao positivo. Em cada pixel, o kernel calcula essencialmente «brilho no meu canto inferior direito menos brilho no meu canto superior esquerdo», que é positivo onde a imagem fica mais brilhante nessa direção e negativo onde fica mais escura. Adicionar 128 recentra a saída com sinal para o cinzento médio para que o efeito seja visível.
Rodar a assimetria ao longo da outra diagonal produz relevo na 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 relevo 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 de detetar a 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). Um bloco plano através de tal kernel produz o mesmo brilho plano, porque o kernel faz a média dos valores de pixel juntos em vez de amplificar as suas diferenças.
O mais simples é o desfoque por caixa, 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, pelo que a divisão automática pela soma do kernel transforma a soma de produtos numa 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 rapidamente, através de um caminho otimizado para calcular a média e nada mais, onde morph executa a maquinaria de convolução geral. O desfoque por caixa está no catálogo porque é a referência certa para compreender todos os outros kernels de suavização.
Uma aproximação 3-por-3 dos pesos Gaussianos pondera o centro e os vizinhos cardinais 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 multiplicada por produto externo consigo mesma. O peso central 4 é o maior porque o pixel central contribui mais para a sua própria saída; os cantos são 1 porque estão mais afastados do centro. O kernel soma 16, e a divisão automática pela soma do kernel trata da normalização – não é necessário argumento mul. A forma 3-por-3 é uma aproximação grosseira de um Gaussiano verdadeiro e indistinguível de gaussian() com size=1; a forma morph é principalmente útil quando uma aplicação quer compor a suavização com outra operação na mesma passagem.
5.17.7. Desfoque de movimento¶
Um kernel de desfoque de movimento 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 fila central faz a média de três pixels ao longo do eixo horizontal; as filas de cima e de baixo são zero. O kernel soma 3, pelo que a divisão automática pela soma do kernel produz uma verdadeira média de três pixels sem necessidade de mul. A saída é uma cópia horizontalmente esborratada da entrada – o efeito que uma câmara capta quando o sujeito se move lateralmente durante a exposição. O desfoque de movimento vertical é o mesmo padrão rodado:
motion_v = [0, 1, 0,
0, 1, 0,
0, 1, 0]
Um desfoque de movimento diagonal usa a diagonal principal:
motion_diag = [1, 0, 0,
0, 1, 0,
0, 0, 1]
img.morph(1, motion_diag)
Os kernels de desfoque de movimento são úteis tanto como efeito (desfocando deliberadamente um fotograma para fins visuais) como como padrão de teste para algoritmos que precisam de ser robustos contra artefactos de movimento (execute o algoritmo numa entrada com desfoque de movimento e verifique que ainda produz a resposta correta).
5.17.8. Leitura de kernels à primeira vista¶
Algumas regras práticas tornam os novos kernels mais fáceis de ler à primeira vista:
Soma igual a um com pesos não negativos ⇒ suavização (preserva o brilho médio).
Soma igual a zero com pesos positivos e negativos ⇒ resposta a arestas (zero em blocos planos).
Soma igual a um com um grande centro positivo e pequenas vizinhanças negativas ⇒ nitidez (identidade mais resposta a arestas).
Assimétrico ao longo de uma diagonal com soma igual a um ⇒ relevo (realça um lado de cada transição de brilho).
Concentrado ao longo de um eixo com soma igual a um ⇒ desfoque direcional.
O primeiro destes que o kernel corresponde é geralmente a suposição correta sobre o que faz. A maioria dos kernels úteis é reconhecível apenas pela disposição do seu padrão de pesos.
Quando nenhum dos kernels standard faz o que a aplicação quer, o próximo passo é ajustar um manualmente. A combinação das regras acima e os controlos mul / add cobre quase todas as passagens lineares que um pipeline clássico de visão por computador alguma vez quis; a partir daí é uma questão de experimentar pesos, observar a saída e iterar.