5.17. Un catálogo de kernels estándar

El procesamiento clásico de imágenes ha acumulado un catálogo de buen tamaño de patrones de pesos de kernel que aparecen una y otra vez – detectores de bordes, realzadores, relieves, suavizadores, desenfoques de movimiento – y cada uno de ellos se ejecuta a través de morph(). Cada uno es breve, cada uno hace una sola cosa, y la mayoría son sencillos de leer una vez que se comprende la lógica básica de los pesos.

Los kernels que se muestran a continuación son todos de 3 por 3 salvo que se indique lo contrario, por lo que todos usan size=1 en la llamada. La estructura de pesos de cada kernel se describe junto a él, ya que leer los pesos es lo que construye la intuición de por qué un kernel produce relieve y otro realza.

5.17.1. El kernel identidad

El kernel más simple posible es el de identidad – uno en el centro, cero en todas las demás posiciones:

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

img.morph(1, identity)

Cada píxel de salida toma su valor del centro del vecindario, que es el píxel de entrada en la misma posición. La imagen pasa sin cambios. La identidad no tiene uso práctico como filtro, pero es la línea base útil para entender todos los demás kernels: cualquier kernel distinto de la identidad es la identidad más alguna modificación.

Un kernel cuyo peso central es grande con pequeños pesos negativos a su alrededor resta el entorno del centro. Un kernel con un peso central de cero ignora el propio píxel y responde únicamente a las diferencias entre sus vecinos. Leer un kernel de esta manera – qué hace el peso central al píxel, qué suman o restan los pesos circundantes – es la forma más rápida de predecir su efecto.

5.17.2. Detección de bordes

Los kernels de detección de bordes responden con fuerza en las posiciones donde el brillo cambia rápidamente en una dirección particular, y producen una salida cercana a cero donde el brillo es uniforme. Son la familia cuyos pesos suman cero: una región plana (cada píxel con el mismo valor) produce una salida de cero, porque cada peso positivo se cancela exactamente con un peso negativo de igual magnitud.

Sobel-x es el ejemplo canónico. Detecta bordes verticales (transiciones de brillo izquierda/derecha):

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

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

El correspondiente Sobel-y es el mismo patrón rotado 90 grados; detecta bordes horizontales (transiciones de brillo arriba/abajo):

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

La fila central de Sobel-x tiene pesos -2 y 2 en lugar de -1 y 1. El peso adicional en la fila central le da al kernel un pequeño suavizado incorporado en la dirección a lo largo del borde, lo que lo hace más robusto frente al ruido que el operador Prewitt, más simple, que prescinde de esas magnitudes adicionales:

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

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

Prewitt pondera todas las filas por igual, por lo que su respuesta es un poco más nítida que la de Sobel, a costa de ser más sensible al ruido de un solo píxel (el coste de ejecutar el kernel es idéntico – la convolución realiza el mismo trabajo cualesquiera que sean los pesos). En una imagen limpia con bordes marcados, es un sustituto perfectamente útil de Sobel.

Scharr va en la otra dirección. Sus pesos son mayores y están ajustados para una detección precisa de la dirección del borde en ángulos más finos:

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

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

El divisor mul=0.0625 (1/16) devuelve la salida al rango 0255 tras la mayor suma de productos. Scharr es la respuesta correcta cuando la aplicación necesita la respuesta de gradiente más fiel geométricamente y está dispuesta a pagar un poco más de aritmética por ello.

5.17.3. El Laplaciano

Un kernel Laplaciano responde a los bordes en cualquier dirección a la vez. Mientras que los Sobel detectan cada uno cambios de brillo a lo largo de un eje, el patrón de pesos simétrico del Laplaciano responde de la misma manera independientemente de la dirección en que vaya el borde:

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

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

La estructura: peso central 4, los cuatro vecinos horizontales/verticales con peso -1, las cuatro diagonales con peso cero. El kernel suma cero, por lo que las regiones planas producen una salida de cero. Donde el brillo cambia, el valor central difiere del promedio de sus cuatro vecinos cardinales, y la salida es el tamaño de esa diferencia.

La variante de 8 conexiones incluye los vecinos diagonales:

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

Cada kernel detecta cosas ligeramente distintas. La versión de 4 conexiones produce una salida más limpia en bordes horizontales y verticales; la de 8 conexiones es más isótropa – responde igual de bien en todas las direcciones – pero produce una salida algo más ruidosa. El kernel de 8 conexiones también circula con el nombre de outline, por su uso para visualizar bordes.

5.17.4. Realce de nitidez

Un kernel de realce de nitidez es la identidad más un kernel de respuesta a bordes. La salida es la imagen original más una copia de los bordes, por lo que las características de alta frecuencia se amplifican en relación con los interiores suaves.

El kernel estándar de realce de nitidez de 4 conexiones suma el Laplaciano de 4 conexiones a la identidad:

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

img.morph(1, sharpen)

Lectura del kernel: el peso central es identity (1) + Laplacian centre (4) = 5, y el entorno coincide con el del Laplaciano. Las regiones planas producen 5 * 1 - 4 * 1 = 1 veces el valor central – la identidad. Los bordes producen el original más la respuesta del Laplaciano. La suma de los pesos es 1, por lo que mul y add permanecen en sus valores predeterminados.

Para un realce más fuerte, la variante de 8 conexiones va más allá:

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

img.morph(1, sharpen_strong)

El peso central 9 es identity (1) + Laplacian-8 centre (8). La misma lógica, más amplificación, más riesgo de amplificar también el ruido del sensor.

Los kernels de realce fuerte son esencialmente gaussian() con unsharp=True, solo que expresados directamente como un kernel en lugar de a través del indicador de máscara de desenfoque (unsharp mask). El comportamiento a nivel de píxel es el mismo; la elección está entre la comodidad del método con nombre y el control fino de un kernel ajustado a mano.

5.17.5. Relieve

Un kernel de relieve (emboss) produce el efecto de iluminación lateral que se encuentra en los editores de imágenes clásicos. La salida parece como si la imagen se hubiera extruido en relieve y luego se hubiera iluminado desde una esquina:

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

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

El truco está en la asimetría a lo largo de la diagonal. La esquina superior izquierda tiene el peso más negativo, la inferior derecha tiene el peso más positivo, y la diagonal de esquina a esquina va de negativo, pasando por uno, hasta positivo. En cada píxel el kernel calcula esencialmente «el brillo en mi parte inferior derecha menos el brillo en mi parte superior izquierda», que es positivo donde la imagen se vuelve más brillante en esa dirección y negativo donde se vuelve más oscura. Sumar 128 recentra la salida con signo al gris medio para que el efecto sea visible.

Rotar la asimetría a lo largo de la otra diagonal produce relieve desde la dirección opuesta:

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

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

Las dos direcciones de relieve son útiles en combinación – restando una de la otra, o ejecutando cada una sobre la misma imagen y comparando las respuestas – cuando una aplicación necesita detectar la orientación.

5.17.6. Suavizado

Los kernels de suavizado son la familia cuyos pesos suman uno (y son todos no negativos). Una región plana procesada por un kernel de este tipo produce el mismo brillo plano, porque el kernel promedia los valores de los píxeles entre sí en lugar de amplificar sus diferencias.

El más simple es el box blur (desenfoque de caja), que es exactamente lo que calcula mean():

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

img.morph(1, box_blur)

El kernel suma 9, por lo que la división automática por la suma del kernel convierte la suma de productos en un verdadero promedio sobre los nueve píxeles del vecindario. En la práctica mean() es la mejor forma de ejecutar este kernel – produce la misma salida más rápido, a través de una ruta optimizada para calcular la media y nada más, mientras que morph ejecuta la maquinaria general de convolución. El box blur está en el catálogo porque es la línea base correcta para entender todos los demás kernels de suavizado.

Una aproximación de 3 por 3 de la Gaussiana pondera el centro y los vecinos cardinales más que las esquinas:

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

img.morph(1, gaussian)

Los pesos son la fila 1, 2, 1 del triángulo de Pascal multiplicada como producto externo por sí misma. El peso central 4 es el mayor porque el píxel central contribuye más a su propia salida; las esquinas son 1 porque son las más alejadas del centro. El kernel suma 16, y la división automática por la suma del kernel se encarga de la normalización – no se necesita el argumento mul. La forma de 3 por 3 es una aproximación tosca de una Gaussiana verdadera e indistinguible de gaussian() con size=1; la forma con morph es útil sobre todo cuando una aplicación quiere componer el suavizado con otra operación en la misma pasada.

5.17.7. Desenfoque de movimiento

Un kernel de desenfoque de movimiento promedia los píxeles a lo largo de una dirección, dejando sin desenfocar la dirección perpendicular. El caso más simple es el horizontal:

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

img.morph(1, motion_h)

La fila central promedia tres píxeles a lo largo del eje horizontal; las filas superior e inferior son cero. El kernel suma 3, por lo que la división automática por la suma del kernel produce un verdadero promedio de tres píxeles sin necesidad de ningún mul. La salida es una copia de la entrada con manchas horizontales – el efecto que capta una cámara cuando el sujeto se mueve lateralmente durante la exposición. El desenfoque de movimiento vertical es el mismo patrón rotado:

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

Un desenfoque de movimiento diagonal usa la diagonal principal:

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

img.morph(1, motion_diag)

Los kernels de desenfoque de movimiento son útiles tanto como un efecto (desenfocar deliberadamente un fotograma con fines visuales) como un patrón de prueba para algoritmos que deben ser robustos frente a artefactos de movimiento (ejecutar el algoritmo sobre una entrada con desenfoque de movimiento y comprobar que sigue produciendo la respuesta correcta).

5.17.8. Leer kernels de un vistazo

Unas pocas reglas prácticas hacen que los nuevos kernels sean más fáciles de leer a simple vista:

  • Suma uno con pesos no negativos ⇒ suavizado (conserva el brillo promedio).

  • Suma cero con pesos tanto positivos como negativos ⇒ respuesta a bordes (cero en regiones planas).

  • Suma uno con un gran centro positivo y pequeños alrededores negativos ⇒ realce de nitidez (identidad más respuesta a bordes).

  • Asimétrico a lo largo de una diagonal con suma uno ⇒ relieve (resalta un lado de cada transición de brillo).

  • Concentrado a lo largo de un eje con suma uno ⇒ desenfoque direccional.

La primera de estas con la que coincida el kernel suele ser la conjetura correcta de lo que hace. La mayoría de los kernels útiles son reconocibles solo por la disposición de su patrón de pesos.

Cuando ninguno de los kernels estándar hace lo que la aplicación quiere, el siguiente paso es ajustar uno a mano. La combinación de las reglas anteriores y los controles mul / add cubre casi cualquier pasada lineal que un pipeline clásico de visión artificial haya deseado alguna vez; a partir de ahí es cuestión de probar pesos, mirar la salida e iterar.