Ayer me quedé hasta las tantas peleándome con un proyecto de visión artificial. El objetivo era sencillo sobre el papel: aislar la silueta de unas piezas mecánicas que pasan por una cinta transportadora. Lo típico. El problema es que la iluminación de la nave industrial donde va esto montado es un absoluto desastre. Usar mi rudimentario detector de bordes por umbral no daba pie con bola. O me sacaba ruido por todas partes (parecía que la imagen tenía sarampión), o perdía los contornos reales de la pieza si le subía mucho la exigencia al filtro.
Después de repasar los viejos apuntes y bucear un rato por foros, me decidí a implementar el famoso detector de bordes de Canny. Ya vimos en el artículo anterior cómo pelear con las cabeceras BMP en C, así que esta vez vamos directos a lo divertido: el algoritmo matemático puro y duro en C.
¿Por qué Canny y no un simple Sobel?
Hasta ahora había estado jugando con el operador Sobel, que no está mal para empezar, pero te deja unas líneas de borde más gordas que un rotulador permanente. Canny, desarrollado por John F. Canny allá por 1986 (sí, tiene sus años pero sigue siendo el rey), soluciona esto de manera brillante y, además, maneja el ruido como un campeón.
El algoritmo de Canny no es una simple función a la que le pasas una imagen y te devuelve otra. Es un proceso en varias etapas muy marcadas:
- Filtro Gaussiano: Suaviza la imagen mediante una convolución espacial para eliminar el ruido de alta frecuencia (el odioso efecto sal y pimienta).
- Cálculo del Gradiente: Encuentra la intensidad y la dirección del borde. Aquí es donde solemos meter las máscaras de Sobel para calcular las derivadas en los ejes X e Y.
- Supresión de no-máximos: Adelgaza los bordes detectados. Básicamente se asegura de que el borde tenga solo un píxel de grosor seleccionando únicamente el máximo local en la dirección del gradiente. Magia pura.
- Umbralización por histéresis: Esta es la parte que más me gusta. Usa dos umbrales (alto y bajo) para decidir qué píxeles son un borde real y cuáles son solo ruido conectado.
Implementando Canny a pelo en C
No voy a pegar todo el código fuente del proyecto porque os reventaría el navegador, pero quiero enseñaros el núcleo de la parte más compleja: el cálculo del gradiente y las direcciones.
Suponiendo que ya tenemos nuestra imagen en memoria, este sería el fragmento donde calculamos la magnitud y dirección de los bordes:
// Asumimos que imagen_suavizada es un array 1D para nuestra imagen 2D en escala de grises
// ancho y alto son las dimensiones obtenidas de la cabecera
int Gx, Gy;
double magnitud;
double direccion;
// Recorremos la imagen evitando los bordes externos para no salirnos de la memoria
for (int y = 1; y < alto - 1; y++) {
for (int x = 1; x < ancho - 1; x++) {
// Operador Sobel en el eje X
Gx = (imagen_suavizada[(y-1)*ancho + (x+1)] + 2*imagen_suavizada[y*ancho + (x+1)] + imagen_suavizada[(y+1)*ancho + (x+1)])
- (imagen_suavizada[(y-1)*ancho + (x-1)] + 2*imagen_suavizada[y*ancho + (x-1)] + imagen_suavizada[(y+1)*ancho + (x-1)]);
// Operador Sobel en el eje Y
Gy = (imagen_suavizada[(y-1)*ancho + (x-1)] + 2*imagen_suavizada[(y-1)*ancho + x] + imagen_suavizada[(y-1)*ancho + (x+1)])
- (imagen_suavizada[(y+1)*ancho + (x-1)] + 2*imagen_suavizada[(y+1)*ancho + x] + imagen_suavizada[(y+1)*ancho + (x+1)]);
// Calculamos la magnitud del vector (usamos aproximación rápida para ahorrar ciclos de CPU)
magnitud = abs(Gx) + abs(Gy);
// Dirección (ángulo en radianes)
direccion = atan2((double)Gy, (double)Gx);
// Aquí guardaríamos los resultados en matrices auxiliares
// matriz_magnitud[y*ancho + x] = magnitud;
// matriz_angulo[y*ancho + x] = direccion;
}
}
Ese código es un for anidado que no tiene mucho misterio, pero es el corazón que hace latir al sistema. Hay que tener muchísimo cuidado con los índices.
Después de este paso vendría la supresión de no-máximos. Es decir, mirar en la dirección que nos ha dado atan2 y comprobar si el píxel actual tiene más valor que sus dos vecinos inmediatos en ese eje. Si no es así, lo suprimimos y lo ponemos a cero (negro). Esto es lo que consigue ese borde tan fino, nítido y profesional. Finalmente, le aplicamos la histéresis y cruzamos los dedos para no llevarnos ruido.
Compilando la criatura
Tiro del compilador de GNU (GCC) directamente desde la terminal, acordándome de enlazar la librería matemática:
gcc -Wall -O2 canny.c -o canny -lm
./canny entrada.bmp salida.bmp 50 150
Nota para despistados: Esos 50 y 150 son mis valores de umbral bajo y alto para el algoritmo de histéresis. Vais a tener que jugar con ellos dependiendo del contraste, la iluminación y la calidad de la cámara con la que toméis las fotos.
Reflexiones sobre la visión artificial
Programar estas cosas desde cero a base de arrays unidimensionales en C es doloroso, no os voy a mentir, pero resulta tremendamente didáctico. Me consta que hay librerías en desarrollo muy potentes, como OpenCV (que acaba de sacar versión hace un par de años), que te resuelven todo este engorro con una sola llamada a la función cvCanny. Pero, sinceramente, entender lo que pasa bajo el capó de la memoria y lidiar con las derivadas me da mucha más tranquilidad cuando el sistema de control falla luego en producción.
Es fascinante lo que podemos llegar a lograr procesando enormes matrices de píxeles a la fuerza bruta. Si los procesadores siguen subiendo de velocidad a este ritmo infernal (ya estamos rozando los 3GHz en los flamantes procesadores Intel Core 2 Duo de doble núcleo), me pregunto si dentro de unos años seremos capaces de aplicar estos algoritmos matemáticos a flujos de vídeo en tiempo real y a alta resolución, directamente sin despeinarnos y sin quemar la CPU. Tiempo al tiempo.