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.
De forma intuitiva, un borde es una zona donde la imagen cambia bruscamente. Ese cambio puede deberse a:
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.
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:
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:
Esta idea está detrás de operadores como Sobel.
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:
Esta secuencia ayuda a reducir respuestas espurias y mejora la estabilidad del resultado.
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.
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.
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.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)
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.
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.
| 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.
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.
Canny se volvió muy popular porque logra un buen equilibrio entre sensibilidad y limpieza. Su pipeline clásico incluye:
El resultado suele ser un mapa de bordes más fino y menos ruidoso que otros métodos más simples.
El detector de Canny depende fuertemente de la elección de sus dos umbrales:
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.
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.
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.
En la práctica, Canny suele ser la opción más usada cuando se busca un detector de bordes general y limpio.
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.
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.
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:
Eso explica por qué el ajuste de parámetros importa tanto.
En imágenes de documentos, la detección de bordes puede servir para:
En este tipo de aplicaciones, Canny combinado con suavizado y umbralización suele ser una herramienta muy útil.
En visión industrial, los bordes ayudan a inspeccionar:
Aquí la estabilidad del borde detectado es muy importante, porque el sistema suele operar de forma repetitiva y automática.
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:
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.
Al trabajar con detección de bordes, algunos errores típicos son:
Un buen resultado en bordes suele requerir ajustar el preprocesamiento y no solo el detector en sí.
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.