En visión por computadora, muchas imágenes llegan con ruido, pequeñas variaciones de intensidad o detalles finos que pueden dificultar el análisis posterior. Antes de detectar bordes, segmentar regiones o alimentar ciertos algoritmos, suele ser útil aplicar operaciones de filtrado y suavizado.
Estas técnicas permiten modificar la imagen considerando no solo el valor de un píxel aislado, sino también la información de sus vecinos. Por eso representan un paso natural después de estudiar operaciones matriciales y transformaciones geométricas.
En este tema veremos qué significa filtrar una imagen, qué es un kernel, cómo funciona la convolución y cuáles son los métodos de suavizado más usados en OpenCV.
Filtrar una imagen significa transformar cada píxel en función de una regla que toma en cuenta una vecindad local. En lugar de mirar un solo valor, el algoritmo considera un pequeño bloque alrededor del píxel actual y calcula un nuevo resultado.
El objetivo puede ser muy distinto según el filtro:
El suavizado es un tipo particular de filtrado cuyo objetivo principal es reducir variaciones abruptas de intensidad. En otras palabras, intenta hacer la imagen más “estable” o menos ruidosa.
Esto puede ser útil cuando:
Sin embargo, suavizar demasiado también puede borrar información importante. Por eso siempre hay un equilibrio entre reducir ruido y conservar detalle.
Cuando hablamos de filtrado, la noción de vecindad es central. Para recalcular un píxel, se suele mirar un bloque cuadrado alrededor de él, por ejemplo de tamaño 3x3, 5x5 o 7x7.
Ese bloque determina cuánta influencia tienen los píxeles cercanos sobre el resultado final. Una vecindad pequeña produce un efecto local más moderado. Una vecindad más grande produce un suavizado más fuerte.
La mayoría de los filtros usan una pequeña matriz llamada kernel o máscara. Esa matriz define cómo se combinan los valores de la vecindad.
Por ejemplo, un kernel simple de promedio 3x3 podría ser:
kernel = [
[1/9, 1/9, 1/9],
[1/9, 1/9, 1/9],
[1/9, 1/9, 1/9]
]
Este kernel asigna el mismo peso a todos los vecinos y calcula el promedio. El resultado es una imagen más suave.
La operación matemática que está detrás de muchísimos filtros se llama convolución. De forma intuitiva, consiste en deslizar un kernel sobre la imagen y, en cada posición, combinar los valores locales con los pesos del kernel.
No hace falta memorizar aquí toda la formulación matemática. Lo importante es entender la idea:
Más adelante veremos que esta misma lógica reaparece en las redes convolucionales.
Desde el punto de vista conceptual, muchos filtros pueden agruparse en dos grandes familias:
En este tema nos enfocaremos sobre todo en filtros de suavizado, es decir, del tipo pasa-bajos. La detección de bordes la estudiaremos en el siguiente tema.
El ruido es una variación indeseada en la señal visual. Puede aparecer por múltiples razones:
Según el tipo de ruido, algunos filtros serán más adecuados que otros. Por eso conviene no pensar el suavizado como una única herramienta, sino como una familia de métodos.
El filtro de promedio o blur simple reemplaza cada píxel por el promedio de sus vecinos. Es una forma intuitiva y directa de suavizar.
Con OpenCV:
import cv2
imagen = cv2.imread("foto1.jpg")
suavizada = cv2.blur(imagen, (5, 5))
cv2.imshow("Imagen original", imagen)
cv2.imshow("Imagen suavizada", suavizada)
cv2.waitKey(0)
cv2.destroyAllWindows()
Aquí el kernel tiene tamaño 5x5. Cuanto mayor sea el kernel, mayor será el efecto de suavizado.
Su ventaja es la simplicidad. Su desventaja es que puede borrar bordes y detalles importantes con bastante facilidad.
OpenCV también ofrece una función más general para este tipo de suavizado:
suavizada = cv2.boxFilter(imagen, -1, (5, 5))
Conceptualmente es muy similar al blur promedio, pero da más control sobre ciertos parámetros internos.
El desenfoque gaussiano es uno de los filtros más usados en visión por computadora. A diferencia del promedio simple, no da el mismo peso a todos los vecinos. Los píxeles más cercanos al centro reciben mayor importancia.
Con OpenCV:
gauss = cv2.GaussianBlur(imagen, (5, 5), 0)
Este filtro suele producir resultados más naturales y menos abruptos que el blur promedio. También es muy común como preprocesamiento antes de detección de bordes o umbralización.
El filtro gaussiano es muy popular por varias razones:
Por eso aparece una y otra vez en pipelines clásicos de visión.
El filtro de mediana funciona de una manera distinta: en lugar de promediar, toma la mediana de los valores dentro de la vecindad. Esto lo vuelve especialmente útil contra ciertos tipos de ruido impulsivo, como el ruido sal y pimienta.
mediana = cv2.medianBlur(imagen, 5)
Su fortaleza es que puede eliminar puntos anómalos aislados sin difuminar tanto los bordes como un promedio simple.
Este filtro suele ser una buena opción cuando:
No siempre será la mejor elección, pero es un recurso muy valioso cuando el ruido aparece como pequeños puntos erráticos.
El filtro bilateral es especialmente interesante porque busca suavizar la imagen sin perder tanto los bordes. Para ello tiene en cuenta no solo la distancia espacial entre píxeles, sino también la diferencia de intensidad.
bilateral = cv2.bilateralFilter(imagen, 9, 75, 75)
Este filtro puede preservar bordes mejor que un blur gaussiano, aunque suele ser más costoso computacionalmente.
El bilateral es útil cuando queremos reducir ruido pero mantener discontinuidades importantes, como contornos de objetos. Sin embargo:
Se usa cuando preservar bordes tiene más valor que la máxima velocidad.
OpenCV permite aplicar kernels definidos manualmente mediante cv2.filter2D. Esto es muy útil cuando queremos experimentar con filtros personalizados.
import numpy as np
kernel = np.ones((3, 3), np.float32) / 9
resultado = cv2.filter2D(imagen, -1, kernel)
Esta función es importante porque expone de manera muy clara la lógica general del filtrado por convolución.
El tamaño del kernel influye mucho en el resultado. Un kernel pequeño, como 3x3, produce un efecto moderado. Un kernel mayor, como 9x9 o 15x15, suaviza mucho más.
Sin embargo, a medida que el kernel crece:
Elegir bien el tamaño del kernel es una cuestión práctica muy importante.
Uno de los dilemas clásicos del filtrado es que muchas técnicas que eliminan ruido también atenúan bordes. Esto ocurre porque los bordes son, precisamente, cambios bruscos de intensidad, y los filtros de suavizado intentan reducir ese tipo de variación.
Por eso, al diseñar un pipeline hay que pensar con cuidado:
La respuesta determinará el tipo de filtro más apropiado.
Los filtros pueden aplicarse tanto a imágenes en grises como a imágenes color. En imágenes color, normalmente se procesan los canales de forma coordinada o se opera directamente sobre la estructura multicanal.
En algunos problemas conviene convertir primero a grises. En otros, interesa conservar el color y filtrar la imagen completa. La decisión depende del tipo de información que será relevante después.
Desde un punto de vista más conceptual, el suavizado atenúa componentes de alta frecuencia espacial. Esto significa que reduce detalles finos y variaciones rápidas en la imagen.
Aunque no necesitamos profundizar aún en análisis en frecuencia, vale la pena recordar esta intuición:
Esta idea reaparecerá más adelante cuando hablemos de detección de bordes y análisis de intensidades.
En muchos pipelines de visión, el filtrado no es el objetivo final, sino un paso previo. Por ejemplo:
Esto convierte al suavizado en una herramienta de preparación más que en un fin en sí mismo.
Veamos ahora un ejemplo integrado más completo, con una aplicación que muestra en tiempo real distintos filtros de suavizado aplicados sobre la cámara:
import tkinter as tk
from tkinter import ttk, messagebox
import cv2
import numpy as np
from PIL import Image, ImageTk
class AplicacionFiltradoCamara:
def __init__(self, root):
self.root = root
self.root.title("Cámara en vivo - Filtrado y suavizado")
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="Tamaño kernel general").pack(anchor="w")
self.scale_kernel = tk.Scale(panel_izquierdo, from_=1, to=25, orient="horizontal", length=250)
self.scale_kernel.set(5)
self.scale_kernel.pack()
ttk.Label(panel_izquierdo, text="Tamaño kernel mediana").pack(anchor="w")
self.scale_kernel_mediana = tk.Scale(panel_izquierdo, from_=1, to=25, orient="horizontal", length=250)
self.scale_kernel_mediana.set(5)
self.scale_kernel_mediana.pack()
ttk.Label(panel_izquierdo, text="Sigma Color bilateral").pack(anchor="w")
self.scale_sigma_color = tk.Scale(panel_izquierdo, from_=1, to=150, orient="horizontal", length=250)
self.scale_sigma_color.set(75)
self.scale_sigma_color.pack()
ttk.Label(panel_izquierdo, text="Sigma Space bilateral").pack(anchor="w")
self.scale_sigma_space = tk.Scale(panel_izquierdo, from_=1, to=150, orient="horizontal", length=250)
self.scale_sigma_space.set(75)
self.scale_sigma_space.pack()
ttk.Label(panel_izquierdo, text="Diámetro bilateral").pack(anchor="w")
self.scale_d_bilateral = tk.Scale(panel_izquierdo, from_=1, to=25, orient="horizontal", length=250)
self.scale_d_bilateral.set(9)
self.scale_d_bilateral.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. Blur promedio\n"
"3. BoxFilter\n"
"4. Gaussian Blur\n"
"5. Median Blur\n"
"6. Bilateral Filter\n"
"7. filter2D personalizado\n"
"8. Gris + Gaussian\n"
"9. Suavizado fuerte"
)
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="Filtrado y suavizado 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", "Blur promedio", "BoxFilter",
"Gaussian Blur", "Median Blur", "Bilateral Filter",
"filter2D personalizado", "Gris + Gaussian", "Suavizado fuerte"
]
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 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
# ---------------------------------------------------------
# FILTROS
# ---------------------------------------------------------
def filtro_blur_promedio(self, frame):
k = self.kernel_impar(self.scale_kernel.get())
return cv2.blur(frame, (k, k))
def filtro_boxfilter(self, frame):
k = self.kernel_impar(self.scale_kernel.get())
return cv2.boxFilter(frame, -1, (k, k))
def filtro_gaussian(self, frame):
k = self.kernel_impar(self.scale_kernel.get())
return cv2.GaussianBlur(frame, (k, k), 0)
def filtro_mediana(self, frame):
k = self.kernel_impar(self.scale_kernel_mediana.get())
return cv2.medianBlur(frame, k)
def filtro_bilateral(self, frame):
d = self.kernel_impar(self.scale_d_bilateral.get())
sigma_color = self.scale_sigma_color.get()
sigma_space = self.scale_sigma_space.get()
return cv2.bilateralFilter(frame, d, sigma_color, sigma_space)
def filtro_personalizado(self, frame):
kernel = np.array([
[1, 2, 1],
[2, 4, 2],
[1, 2, 1]
], dtype=np.float32)
kernel = kernel / kernel.sum()
return cv2.filter2D(frame, -1, kernel)
def filtro_gris_gaussian(self, frame):
gris = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
k = self.kernel_impar(self.scale_kernel.get())
return cv2.GaussianBlur(gris, (k, k), 0)
def filtro_suavizado_fuerte(self, frame):
k = self.kernel_impar(self.scale_kernel.get())
k_fuerte = min(k + 8, 31)
if k_fuerte % 2 == 0:
k_fuerte += 1
return cv2.GaussianBlur(frame, (k_fuerte, k_fuerte), 0)
# ---------------------------------------------------------
# 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()
original = frame_base
blur_promedio = self.filtro_blur_promedio(frame_base)
boxfilter = self.filtro_boxfilter(frame_base)
gaussian = self.filtro_gaussian(frame_base)
mediana = self.filtro_mediana(frame_base)
bilateral = self.filtro_bilateral(frame_base)
personalizado = self.filtro_personalizado(frame_base)
gris_gaussian = self.filtro_gris_gaussian(frame_base)
fuerte = self.filtro_suavizado_fuerte(frame_base)
self.mostrar_en_label(original, self.labels_imagen["Original"])
self.mostrar_en_label(blur_promedio, self.labels_imagen["Blur promedio"])
self.mostrar_en_label(boxfilter, self.labels_imagen["BoxFilter"])
self.mostrar_en_label(gaussian, self.labels_imagen["Gaussian Blur"])
self.mostrar_en_label(mediana, self.labels_imagen["Median Blur"])
self.mostrar_en_label(bilateral, self.labels_imagen["Bilateral Filter"])
self.mostrar_en_label(personalizado, self.labels_imagen["filter2D personalizado"])
self.mostrar_en_label(gris_gaussian, self.labels_imagen["Gris + Gaussian"])
self.mostrar_en_label(fuerte, self.labels_imagen["Suavizado fuerte"])
self.root.after(30, self.actualizar_video)
if __name__ == "__main__":
root = tk.Tk()
app = AplicacionFiltradoCamara(root)
root.mainloop()
Este programa permite comparar visualmente varios métodos de suavizado sobre video en vivo, lo que ayuda a desarrollar intuición práctica sobre sus diferencias y aplicaciones.
No existe un filtro universalmente mejor. La elección depende del problema:
En proyectos reales, esta decisión muchas veces se toma experimentando con ejemplos representativos del problema.
Al empezar a trabajar con filtrado y suavizado, algunos errores frecuentes son:
La clave está en filtrar lo suficiente para ayudar al siguiente paso, pero no tanto como para perjudicar la información relevante.
El filtrado y el suavizado de imágenes son herramientas fundamentales porque permiten controlar ruido, estabilizar la señal visual y preparar la imagen para tareas posteriores más sensibles. Aunque conceptualmente simples, tienen un impacto enorme en la calidad del pipeline.
Dominar estos filtros también ayuda a entender mejor cómo la información local de una imagen puede combinarse y transformarse para lograr un objetivo concreto.
En el próximo tema estudiaremos la detección de bordes, donde veremos cómo pasar del suavizado a técnicas que justamente buscan resaltar cambios bruscos de intensidad.