14. Detección de bordes

14.1 Introducción

Los bordes son una de las estructuras más importantes en una imagen. Muchas veces representan límites entre objetos, transiciones entre regiones o cambios relevantes de intensidad. Detectarlos permite resumir la información visual y resaltar la forma de los elementos presentes en la escena.

En visión por computadora clásica, la detección de bordes es una herramienta fundamental. Sirve como paso previo para contornos, segmentación, reconocimiento de formas, alineación y análisis estructural de imágenes.

En este tema estudiaremos qué es un borde, por qué está relacionado con cambios bruscos de intensidad y cómo implementarlo con OpenCV usando operadores como Sobel, Laplacian y Canny.

14.2 ¿Qué es un borde?

De forma intuitiva, un borde es una zona donde la imagen cambia bruscamente. Ese cambio puede deberse a:

  • Diferencias de iluminación.
  • Cambios de color.
  • Límites físicos entre objetos.
  • Texturas muy contrastadas.

Desde el punto de vista matemático, un borde suele corresponder a una variación fuerte en los valores de intensidad. Por eso la detección de bordes está estrechamente vinculada al concepto de gradiente.

Un borde no es un objeto en sí mismo. Es una zona de transición donde la señal visual cambia de forma significativa.

14.3 ¿Por qué detectar bordes?

Detectar bordes permite reducir la imagen a una representación más estructural. En vez de trabajar con todas las intensidades, nos concentramos en las regiones donde ocurren cambios importantes.

Esto resulta útil en tareas como:

  • Detección de contornos.
  • Segmentación de objetos.
  • Reconocimiento de formas.
  • OCR y análisis de documentos.
  • Inspección industrial.
  • Preprocesamiento antes de ciertos análisis geométricos.

14.4 Bordes y gradiente

El gradiente mide cómo cambia la intensidad de la imagen en el espacio. Si la intensidad cambia poco, el gradiente es pequeño. Si cambia mucho en una zona reducida, el gradiente es grande.

Por eso, una estrategia central para detectar bordes consiste en estimar derivadas o gradientes en distintas direcciones.

En términos simples:

  • Un borde horizontal fuerte se detecta mirando cambios verticales.
  • Un borde vertical fuerte se detecta mirando cambios horizontales.

Esta idea está detrás de operadores como Sobel.

14.5 Importancia del preprocesamiento

Antes de detectar bordes, suele ser conveniente suavizar la imagen. Esto se debe a que el ruido también genera cambios abruptos de intensidad y puede confundirse con bordes reales.

Por eso, en muchos pipelines se sigue este orden:

  1. Convertir a escala de grises.
  2. Aplicar un suavizado, por ejemplo gaussiano.
  3. Detectar bordes.

Esta secuencia ayuda a reducir respuestas espurias y mejora la estabilidad del resultado.

14.6 Conversión a escala de grises

Aunque es posible detectar cambios en imágenes color, lo más habitual en técnicas clásicas es trabajar sobre una sola componente de intensidad:

gris = cv2.cvtColor(imagen, cv2.COLOR_BGR2GRAY)

Esto simplifica el análisis y reduce la complejidad del cálculo de gradientes.

14.7 Suavizado previo

Una práctica muy común es aplicar un desenfoque gaussiano antes de detectar bordes:

gris_suave = cv2.GaussianBlur(gris, (5, 5), 0)

Este paso reduce ruido y evita que pequeñas variaciones locales generen una cantidad excesiva de bordes falsos.

14.8 Operador Sobel

El operador Sobel estima el gradiente de la imagen en una dirección concreta. Se puede calcular en el eje X, en el eje Y o en ambos.

En OpenCV:

sobel_x = cv2.Sobel(gris, cv2.CV_64F, 1, 0, ksize=3)
sobel_y = cv2.Sobel(gris, cv2.CV_64F, 0, 1, ksize=3)

Aquí:

  • dx=1, dy=0 detecta cambios horizontales, es decir, resalta bordes verticales.
  • dx=0, dy=1 detecta cambios verticales, es decir, resalta bordes horizontales.

14.9 Interpretación de Sobel

El operador Sobel no devuelve una imagen “bonita” por sí sola, sino una estimación del cambio de intensidad. Los valores positivos y negativos indican dirección del cambio, mientras que la magnitud indica cuán fuerte es la transición.

Con frecuencia se toma el valor absoluto para visualizar mejor el resultado:

sobel_x_abs = cv2.convertScaleAbs(sobel_x)
sobel_y_abs = cv2.convertScaleAbs(sobel_y)

14.10 Combinación de gradientes

Muchas veces no queremos bordes solo en una dirección, sino una medida combinada. Una forma práctica es sumar ponderadamente ambos resultados:

bordes = cv2.addWeighted(sobel_x_abs, 0.5, sobel_y_abs, 0.5, 0)

Esto produce una imagen que resalta cambios tanto horizontales como verticales.

14.11 Laplacian

Otro operador clásico es Laplacian, que utiliza derivadas de segundo orden. A diferencia de Sobel, no separa explícitamente por dirección, sino que busca cambios de intensidad de manera más global.

lap = cv2.Laplacian(gris, cv2.CV_64F)
lap_abs = cv2.convertScaleAbs(lap)

El Laplaciano puede ser útil para resaltar contornos, aunque suele ser más sensible al ruido si no se aplica un suavizado previo.

14.12 Sobel versus Laplacian

Operador Idea principal Ventaja
Sobel Gradiente en direcciones X e Y. Permite analizar orientación del borde.
Laplacian Derivada de segundo orden. Detección más global de cambios abruptos.

En la práctica, Sobel suele ser más interpretativo, mientras que Laplacian puede ser útil como operador general de realce de contornos.

14.13 El detector de Canny

Uno de los métodos más famosos y útiles para detección de bordes es el algoritmo de Canny. No se limita a calcular gradientes, sino que combina varias etapas para producir bordes más limpios y consistentes.

En OpenCV se aplica así:

canny = cv2.Canny(gris_suave, 100, 200)

Los dos últimos valores son umbrales que controlan la sensibilidad del detector.

14.14 ¿Por qué Canny es tan importante?

Canny se volvió muy popular porque logra un buen equilibrio entre sensibilidad y limpieza. Su pipeline clásico incluye:

  1. Suavizado gaussiano.
  2. Cálculo del gradiente.
  3. Supresión de no máximos.
  4. Umbralización con histéresis.

El resultado suele ser un mapa de bordes más fino y menos ruidoso que otros métodos más simples.

14.15 Umbrales en Canny

El detector de Canny depende fuertemente de la elección de sus dos umbrales:

  • Un umbral bajo para detectar candidatos débiles.
  • Un umbral alto para confirmar bordes fuertes.

Los valores adecuados dependen de la imagen y del problema. Si son demasiado bajos, aparecerán muchos bordes espurios. Si son demasiado altos, se perderán bordes reales.

14.16 Supresión de no máximos

Una idea clave de Canny es la supresión de no máximos. Esto significa que, una vez detectado el gradiente, no se conserva toda la banda de cambio, sino solo los puntos donde la respuesta es localmente máxima en la dirección del gradiente.

Gracias a esto, los bordes detectados son más delgados y mejor definidos.

14.17 Histéresis

La histéresis es otra idea importante de Canny. En lugar de decidir simplemente “borde o no borde” con un único umbral, usa dos niveles para conectar información fuerte y débil.

Esto permite conservar segmentos de borde débiles si están conectados a bordes fuertes, evitando a la vez que se acumulen demasiados falsos positivos aislados.

14.18 Comparación conceptual entre métodos

  • Sobel: útil para analizar gradientes por dirección.
  • Laplacian: resalta cambios bruscos con derivadas de segundo orden.
  • Canny: ofrece bordes más refinados y robustos.

En la práctica, Canny suele ser la opción más usada cuando se busca un detector de bordes general y limpio.

14.19 Bordes y contornos

Conviene distinguir entre bordes y contornos. Un detector de bordes genera un mapa de cambios de intensidad. A partir de ese mapa, otro algoritmo puede buscar contornos, es decir, estructuras conectadas que delimitan regiones u objetos.

En OpenCV es muy común detectar primero bordes y luego aplicar funciones como findContours en una etapa posterior.

14.20 Sensibilidad al ruido

La detección de bordes es especialmente sensible al ruido porque el ruido también produce cambios locales abruptos. Por eso el suavizado previo no es opcional en muchos casos, sino una parte esencial del pipeline.

Esto refuerza la conexión con el tema anterior: filtrado y detección de bordes suelen ir de la mano.

14.21 Bordes fuertes y bordes débiles

No todos los bordes tienen la misma importancia visual. Algunos corresponden a límites muy marcados entre regiones, mientras que otros son transiciones suaves o poco estables.

Elegir qué bordes conservar depende del objetivo:

  • Para documentos puede interesar conservar líneas bien definidas.
  • Para medicina quizás convenga no perder transiciones sutiles.
  • Para visión industrial tal vez se prefiera detectar solo límites muy claros.

Eso explica por qué el ajuste de parámetros importa tanto.

14.22 Uso práctico en documentos y OCR

En imágenes de documentos, la detección de bordes puede servir para:

  • Encontrar los límites de una hoja.
  • Detectar tablas o recuadros.
  • Resaltar estructuras antes de aplicar OCR.

En este tipo de aplicaciones, Canny combinado con suavizado y umbralización suele ser una herramienta muy útil.

14.23 Uso práctico en industria

En visión industrial, los bordes ayudan a inspeccionar:

  • Contornos de piezas.
  • Presencia o ausencia de componentes.
  • Defectos visibles en bordes o uniones.
  • Posición relativa de elementos.

Aquí la estabilidad del borde detectado es muy importante, porque el sistema suele operar de forma repetitiva y automática.

14.24 Ejemplo integrado

Veamos un ejemplo integrado con interfaz gráfica, cámara en vivo y visualización simultánea de varias técnicas de detección de bordes:

Aplicación gráfica de detección de bordes en tiempo real con OpenCV

import tkinter as tk
from tkinter import ttk, messagebox
import cv2
import numpy as np
from PIL import Image, ImageTk


class AplicacionBordesCamara:
    def __init__(self, root):
        self.root = root
        self.root.title("Cámara en vivo - Detección de bordes")
        self.root.geometry("1600x950")

        self.cap = None
        self.ejecutando = False

        self.ancho_celda = 300
        self.alto_celda = 180

        self.crear_interfaz()

    # ---------------------------------------------------------
    # INTERFAZ
    # ---------------------------------------------------------
    def crear_interfaz(self):
        contenedor = ttk.Frame(self.root, padding=10)
        contenedor.pack(fill="both", expand=True)

        # Panel izquierdo
        panel_izquierdo = ttk.Frame(contenedor)
        panel_izquierdo.pack(side="left", fill="y", padx=(0, 10))

        ttk.Label(
            panel_izquierdo,
            text="Cámara y parámetros",
            font=("Arial", 12, "bold")
        ).pack(anchor="w", pady=(0, 8))

        ttk.Button(
            panel_izquierdo,
            text="Iniciar cámara",
            command=self.iniciar_camara
        ).pack(fill="x", pady=2)

        ttk.Button(
            panel_izquierdo,
            text="Detener cámara",
            command=self.detener_camara
        ).pack(fill="x", pady=2)

        ttk.Separator(panel_izquierdo, orient="horizontal").pack(fill="x", pady=10)

        ttk.Label(panel_izquierdo, text="Kernel Gaussian").pack(anchor="w")
        self.scale_kernel_gaussian = tk.Scale(
            panel_izquierdo, from_=1, to=25, orient="horizontal", length=250
        )
        self.scale_kernel_gaussian.set(5)
        self.scale_kernel_gaussian.pack()

        ttk.Label(panel_izquierdo, text="Kernel Sobel").pack(anchor="w")
        self.scale_kernel_sobel = tk.Scale(
            panel_izquierdo, from_=1, to=7, orient="horizontal", length=250
        )
        self.scale_kernel_sobel.set(3)
        self.scale_kernel_sobel.pack()

        ttk.Label(panel_izquierdo, text="Umbral 1 Canny").pack(anchor="w")
        self.scale_canny_1 = tk.Scale(
            panel_izquierdo, from_=0, to=255, orient="horizontal", length=250
        )
        self.scale_canny_1.set(100)
        self.scale_canny_1.pack()

        ttk.Label(panel_izquierdo, text="Umbral 2 Canny").pack(anchor="w")
        self.scale_canny_2 = tk.Scale(
            panel_izquierdo, from_=0, to=255, orient="horizontal", length=250
        )
        self.scale_canny_2.set(200)
        self.scale_canny_2.pack()

        ttk.Label(panel_izquierdo, text="Interpolación visual").pack(anchor="w")
        self.combo_interpolacion = ttk.Combobox(
            panel_izquierdo,
            state="readonly",
            values=["INTER_NEAREST", "INTER_LINEAR", "INTER_CUBIC", "INTER_AREA"],
            width=20
        )
        self.combo_interpolacion.current(1)
        self.combo_interpolacion.pack(fill="x", pady=4)

        self.var_espejo = tk.BooleanVar(value=True)
        ttk.Checkbutton(
            panel_izquierdo,
            text="Mostrar cámara espejada",
            variable=self.var_espejo
        ).pack(anchor="w", pady=6)

        ttk.Separator(panel_izquierdo, orient="horizontal").pack(fill="x", pady=10)

        texto_info = (
            "Grilla en tiempo real:\n"
            "1. Original\n"
            "2. Escala de grises\n"
            "3. Gris + Gaussian\n"
            "4. Sobel X\n"
            "5. Sobel Y\n"
            "6. Sobel combinado\n"
            "7. Laplacian\n"
            "8. Canny\n"
            "9. Contornos detectados"
        )
        ttk.Label(
            panel_izquierdo,
            text=texto_info,
            foreground="blue",
            justify="left"
        ).pack(anchor="w")

        self.label_estado = ttk.Label(
            panel_izquierdo,
            text="Cámara detenida",
            foreground="red"
        )
        self.label_estado.pack(anchor="w", pady=(12, 0))

        # Panel derecho
        panel_derecho = ttk.Frame(contenedor)
        panel_derecho.pack(side="left", fill="both", expand=True)

        ttk.Label(
            panel_derecho,
            text="Detección de bordes en tiempo real",
            font=("Arial", 13, "bold")
        ).pack(anchor="center", pady=(0, 10))

        self.frame_grilla = ttk.Frame(panel_derecho)
        self.frame_grilla.pack(fill="both", expand=True)

        self.labels_imagen = {}
        self._crear_grilla_imagenes()

        self.root.protocol("WM_DELETE_WINDOW", self.al_cerrar)

    def _crear_grilla_imagenes(self):
        nombres = [
            "Original", "Escala de grises", "Gris + Gaussian",
            "Sobel X", "Sobel Y", "Sobel combinado",
            "Laplacian", "Canny", "Contornos detectados"
        ]

        indice = 0
        for fila in range(3):
            self.frame_grilla.rowconfigure(fila, weight=1)
            for col in range(3):
                self.frame_grilla.columnconfigure(col, weight=1)

                marco = ttk.Frame(
                    self.frame_grilla,
                    relief="solid",
                    borderwidth=1,
                    padding=5
                )
                marco.grid(row=fila, column=col, padx=5, pady=5, sticky="nsew")

                titulo = ttk.Label(
                    marco,
                    text=nombres[indice],
                    font=("Arial", 10, "bold")
                )
                titulo.pack(anchor="center", pady=(0, 4))

                label = ttk.Label(marco, anchor="center")
                label.pack(fill="both", expand=True)

                self.labels_imagen[nombres[indice]] = label
                indice += 1

    # ---------------------------------------------------------
    # CÁMARA
    # ---------------------------------------------------------
    def iniciar_camara(self):
        if self.ejecutando:
            return

        self.cap = cv2.VideoCapture(0)
        if not self.cap.isOpened():
            messagebox.showerror("Error", "No se pudo abrir la cámara.")
            return

        self.ejecutando = True
        self.label_estado.config(text="Cámara en ejecución", foreground="green")
        self.actualizar_video()

    def detener_camara(self):
        self.ejecutando = False
        if self.cap is not None:
            self.cap.release()
            self.cap = None
        self.label_estado.config(text="Cámara detenida", foreground="red")

    def al_cerrar(self):
        self.detener_camara()
        self.root.destroy()

    # ---------------------------------------------------------
    # UTILIDADES
    # ---------------------------------------------------------
    def obtener_interpolacion(self):
        nombre = self.combo_interpolacion.get()
        mapa = {
            "INTER_NEAREST": cv2.INTER_NEAREST,
            "INTER_LINEAR": cv2.INTER_LINEAR,
            "INTER_CUBIC": cv2.INTER_CUBIC,
            "INTER_AREA": cv2.INTER_AREA
        }
        return mapa.get(nombre, cv2.INTER_LINEAR)

    def kernel_impar(self, valor):
        k = int(valor)
        if k < 1:
            k = 1
        if k % 2 == 0:
            k += 1
        return k

    def kernel_sobel_valido(self, valor):
        k = int(valor)
        # En OpenCV para Sobel normalmente conviene 1, 3, 5 o 7
        # Si el usuario elige par, lo hacemos impar.
        if k < 1:
            k = 1
        if k % 2 == 0:
            k += 1
        if k > 7:
            k = 7
        return k

    def mostrar_en_label(self, frame_bgr, label):
        if frame_bgr is None:
            return

        if len(frame_bgr.shape) == 2:
            frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_GRAY2RGB)
        else:
            frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)

        h, w = frame_rgb.shape[:2]
        escala = min(self.ancho_celda / w, self.alto_celda / h)

        nuevo_w = max(1, int(w * escala))
        nuevo_h = max(1, int(h * escala))

        frame_redim = cv2.resize(
            frame_rgb,
            (nuevo_w, nuevo_h),
            interpolation=self.obtener_interpolacion()
        )

        lienzo = np.zeros((self.alto_celda, self.ancho_celda, 3), dtype=np.uint8)

        x = (self.ancho_celda - nuevo_w) // 2
        y = (self.alto_celda - nuevo_h) // 2
        lienzo[y:y + nuevo_h, x:x + nuevo_w] = frame_redim

        imagen_pil = Image.fromarray(lienzo)
        imagen_tk = ImageTk.PhotoImage(imagen_pil)

        label.configure(image=imagen_tk)
        label.image = imagen_tk

    # ---------------------------------------------------------
    # PREPROCESAMIENTO
    # ---------------------------------------------------------
    def obtener_gris(self, frame):
        return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

    def obtener_gris_suave(self, gris):
        k = self.kernel_impar(self.scale_kernel_gaussian.get())
        return cv2.GaussianBlur(gris, (k, k), 0)

    # ---------------------------------------------------------
    # DETECCIÓN DE BORDES
    # ---------------------------------------------------------
    def obtener_sobel_x(self, gris_suave):
        k = self.kernel_sobel_valido(self.scale_kernel_sobel.get())
        sobel_x = cv2.Sobel(gris_suave, cv2.CV_64F, 1, 0, ksize=k)
        return cv2.convertScaleAbs(sobel_x)

    def obtener_sobel_y(self, gris_suave):
        k = self.kernel_sobel_valido(self.scale_kernel_sobel.get())
        sobel_y = cv2.Sobel(gris_suave, cv2.CV_64F, 0, 1, ksize=k)
        return cv2.convertScaleAbs(sobel_y)

    def obtener_sobel_combinado(self, sobel_x_abs, sobel_y_abs):
        return cv2.addWeighted(sobel_x_abs, 0.5, sobel_y_abs, 0.5, 0)

    def obtener_laplacian(self, gris_suave):
        lap = cv2.Laplacian(gris_suave, cv2.CV_64F)
        return cv2.convertScaleAbs(lap)

    def obtener_canny(self, gris_suave):
        t1 = self.scale_canny_1.get()
        t2 = self.scale_canny_2.get()

        # Por prolijidad, si el usuario pone el umbral bajo mayor que el alto,
        # los intercambiamos.
        if t1 > t2:
            t1, t2 = t2, t1

        return cv2.Canny(gris_suave, t1, t2)

    def obtener_contornos_dibujados(self, frame_base, canny):
        contornos, _ = cv2.findContours(
            canny.copy(),
            cv2.RETR_EXTERNAL,
            cv2.CHAIN_APPROX_SIMPLE
        )

        resultado = frame_base.copy()
        cv2.drawContours(resultado, contornos, -1, (0, 255, 0), 2)
        return resultado

    # ---------------------------------------------------------
    # ACTUALIZACIÓN
    # ---------------------------------------------------------
    def actualizar_video(self):
        if not self.ejecutando or self.cap is None:
            return

        ok, frame = self.cap.read()
        if not ok:
            self.root.after(30, self.actualizar_video)
            return

        frame = cv2.resize(frame, (640, 480))

        if self.var_espejo.get():
            frame_base = cv2.flip(frame, 1)
        else:
            frame_base = frame.copy()

        gris = self.obtener_gris(frame_base)
        gris_suave = self.obtener_gris_suave(gris)

        sobel_x = self.obtener_sobel_x(gris_suave)
        sobel_y = self.obtener_sobel_y(gris_suave)
        sobel_combinado = self.obtener_sobel_combinado(sobel_x, sobel_y)
        laplacian = self.obtener_laplacian(gris_suave)
        canny = self.obtener_canny(gris_suave)
        contornos = self.obtener_contornos_dibujados(frame_base, canny)

        self.mostrar_en_label(frame_base, self.labels_imagen["Original"])
        self.mostrar_en_label(gris, self.labels_imagen["Escala de grises"])
        self.mostrar_en_label(gris_suave, self.labels_imagen["Gris + Gaussian"])
        self.mostrar_en_label(sobel_x, self.labels_imagen["Sobel X"])
        self.mostrar_en_label(sobel_y, self.labels_imagen["Sobel Y"])
        self.mostrar_en_label(sobel_combinado, self.labels_imagen["Sobel combinado"])
        self.mostrar_en_label(laplacian, self.labels_imagen["Laplacian"])
        self.mostrar_en_label(canny, self.labels_imagen["Canny"])
        self.mostrar_en_label(contornos, self.labels_imagen["Contornos detectados"])

        self.root.after(30, self.actualizar_video)


if __name__ == "__main__":
    root = tk.Tk()
    app = AplicacionBordesCamara(root)
    root.mainloop()

Este ejemplo permite comparar en tiempo real la imagen original, sus distintas transformaciones y los contornos detectados a partir de Canny.

14.25 Errores comunes

Al trabajar con detección de bordes, algunos errores típicos son:

  • No suavizar la imagen antes de detectar.
  • Elegir umbrales de Canny arbitrariamente sin probar.
  • Esperar contornos limpios en imágenes muy ruidosas.
  • Confundir un mapa de bordes con una segmentación completa.
  • Olvidar que algunos bordes visuales dependen del canal o del contraste.

Un buen resultado en bordes suele requerir ajustar el preprocesamiento y no solo el detector en sí.

14.26 Qué debes recordar de este tema

  • Un borde es una zona de cambio fuerte en la intensidad o en la señal visual.
  • La detección de bordes está estrechamente relacionada con el gradiente.
  • Sobel permite estimar cambios por dirección.
  • Laplacian detecta cambios de forma más global con derivadas de segundo orden.
  • Canny es uno de los detectores de bordes más útiles y robustos en práctica.
  • El suavizado previo es muy importante para evitar bordes falsos por ruido.
  • Los bordes suelen actuar como base para contornos y análisis estructural posterior.

14.27 Conclusión

La detección de bordes permite pasar desde una imagen de intensidades a una representación más estructural, donde los cambios relevantes quedan resaltados. Esto la convierte en una técnica central de la visión por computadora clásica.

Además, este tema muestra con claridad cómo se conectan varias ideas del curso: representación numérica, operaciones matriciales, suavizado, gradientes y análisis local de la imagen.

En el próximo tema estudiaremos los histogramas y el análisis de intensidades, que nos permitirán entender la distribución global de valores en una imagen y abrir nuevas herramientas para mejorar contraste y segmentación.