Hasta ahora trabajamos con imágenes desde un punto de vista local: vecindades, filtros, gradientes, bordes y transformaciones geométricas. En este tema vamos a cambiar de escala y mirar la imagen desde una perspectiva más global: la distribución de sus intensidades.
Para eso usamos una herramienta fundamental en visión por computadora y procesamiento de imágenes: el histograma. Un histograma resume cuántos píxeles tienen cada nivel de intensidad o color, y por lo tanto nos permite entender si una imagen es oscura, clara, contrastada, plana o desbalanceada.
Esta herramienta resulta muy útil para análisis exploratorio, mejora de contraste, segmentación y normalización visual.
Un histograma de imagen es una representación de la frecuencia con la que aparecen ciertos valores de intensidad. En una imagen en escala de grises de 8 bits, esos valores suelen ir de 0 a 255.
Eso significa que el histograma nos indica:
En vez de mirar cada píxel individualmente, el histograma resume la distribución global de la imagen.
Podemos pensar el histograma como una gráfica donde:
Si la mayor parte del histograma está concentrada en valores bajos, la imagen tenderá a ser oscura. Si se concentra en valores altos, tenderá a ser clara. Si ocupa un rango amplio de intensidades, habrá más contraste.
En imágenes en escala de grises, el análisis es directo porque cada píxel tiene una sola intensidad. Un histograma típico puede construirse fácilmente con OpenCV o con Matplotlib.
Con OpenCV:
hist = cv2.calcHist([gris], [0], None, [256], [0, 256])
Aquí:
[gris] es la imagen.[0] indica el canal.None indica que no usamos máscara.[256] indica la cantidad de bins.[0, 256] define el rango de intensidades.Una forma muy habitual de visualizar histogramas es con Matplotlib:
import cv2
import matplotlib.pyplot as plt
imagen = cv2.imread("foto1.jpg")
if imagen is None:
raise ValueError("No se pudo cargar la imagen")
gris = cv2.cvtColor(imagen, cv2.COLOR_BGR2GRAY)
plt.hist(gris.ravel(), bins=256, range=[0, 256])
plt.title("Histograma")
plt.xlabel("Intensidad")
plt.ylabel("Cantidad de pixeles")
plt.show()
La función ravel() convierte la matriz en una secuencia unidimensional de valores, lo que facilita construir el histograma.
Uno de los usos más importantes del histograma es analizar el contraste de la imagen:
Esto se debe a que el contraste depende de cuán separadas estén las intensidades entre regiones oscuras y claras.
Un histograma también da una idea rápida del nivel general de luminosidad:
Esta observación es útil para decidir si conviene aplicar correcciones de brillo o contraste.
En una imagen color, no hay un único histograma posible. Podemos calcular uno por canal. Por ejemplo, en una imagen BGR o RGB podemos obtener histogramas separados para cada componente.
Con OpenCV:
import cv2
import matplotlib.pyplot as plt
imagen = cv2.imread("foto1.jpg")
if imagen is None:
raise ValueError("No se pudo cargar la imagen")
hist_b = cv2.calcHist([imagen], [0], None, [256], [0, 256])
hist_g = cv2.calcHist([imagen], [1], None, [256], [0, 256])
hist_r = cv2.calcHist([imagen], [2], None, [256], [0, 256])
plt.figure(figsize=(10, 4))
plt.plot(hist_b, color="blue", label="Canal azul")
plt.plot(hist_g, color="green", label="Canal verde")
plt.plot(hist_r, color="red", label="Canal rojo")
plt.title("Histogramas por canal")
plt.xlabel("Intensidad")
plt.ylabel("Cantidad de pixeles")
plt.xlim([0, 256])
plt.legend()
plt.show()
Esto permite analizar cómo se distribuyen las intensidades en cada canal por separado.
Los histogramas por canal son útiles cuando queremos:
Por ejemplo, si el canal azul domina claramente sobre los demás, la imagen podría tener un sesgo frío o estar influida por ciertas condiciones de iluminación.
No siempre queremos analizar toda la imagen. A veces interesa estudiar solo una región. Para eso pueden utilizarse máscaras.
Ejemplo:
import cv2
import numpy as np
import matplotlib.pyplot as plt
imagen = cv2.imread("foto1.jpg")
if imagen is None:
raise ValueError("No se pudo cargar la imagen")
gris = cv2.cvtColor(imagen, cv2.COLOR_BGR2GRAY)
mascara = np.zeros(gris.shape[:2], dtype="uint8")
mascara[100:300, 150:400] = 255
roi = cv2.bitwise_and(gris, gris, mask=mascara)
hist_roi = cv2.calcHist([gris], [0], mascara, [256], [0, 256])
plt.figure(figsize=(12, 4))
plt.subplot(1, 3, 1)
plt.imshow(gris, cmap="gray")
plt.title("Imagen en grises")
plt.axis("off")
plt.subplot(1, 3, 2)
plt.imshow(roi, cmap="gray")
plt.title("ROI con máscara")
plt.axis("off")
plt.subplot(1, 3, 3)
plt.plot(hist_roi, color="black")
plt.title("Histograma de la ROI")
plt.xlabel("Intensidad")
plt.ylabel("Cantidad de pixeles")
plt.xlim([0, 256])
plt.tight_layout()
plt.show()
Esto permite calcular el histograma únicamente dentro de la región marcada por la máscara.
Además del histograma estándar, a veces se usa el histograma acumulado. En lugar de indicar cuántos píxeles tienen un valor exacto, indica cuántos píxeles tienen intensidad menor o igual a cierto nivel.
Esta idea es importante porque está directamente relacionada con transformaciones como la ecualización del histograma.
La ecualización es una técnica destinada a mejorar el contraste redistribuyendo las intensidades de forma más uniforme. La idea general es expandir el uso del rango dinámico disponible.
Con OpenCV, en imágenes en grises, puede hacerse así:
ecualizada = cv2.equalizeHist(gris)
El resultado suele hacer más visibles ciertas regiones cuando la imagen original tiene contraste pobre.
La ecualización puede ser útil cuando:
Sin embargo, no siempre mejora la imagen de forma deseable. En algunos casos puede exagerar ruido o alterar demasiado la apariencia.
Una variante muy útil es CLAHE (Contrast Limited Adaptive Histogram Equalization). En lugar de ecualizar toda la imagen globalmente, trabaja por regiones locales y además limita el realce excesivo.
En OpenCV:
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
resultado = clahe.apply(gris)
CLAHE es especialmente útil en imágenes con iluminación desigual o cuando queremos mejorar contraste local sin generar artefactos demasiado fuertes.
El histograma también ayuda a entender si una imagen podría segmentarse bien con un umbral global. Por ejemplo:
Esto conecta directamente con técnicas como Otsu, que buscan automáticamente un buen umbral a partir de la distribución de intensidades.
El método de Otsu busca automáticamente un umbral que separe lo mejor posible dos grupos de intensidades. Se apoya justamente en el histograma de la imagen.
Con OpenCV:
_, otsu = cv2.threshold(
gris,
0,
255,
cv2.THRESH_BINARY + cv2.THRESH_OTSU
)
Esto evita fijar manualmente el umbral y resulta útil cuando la imagen tiene una separación relativamente clara entre foreground y background.
El rango dinámico se refiere al intervalo de intensidades efectivamente usado por la imagen. Una imagen puede tener valores posibles de 0 a 255, pero si casi todos sus píxeles están entre 80 y 140, en la práctica está usando un rango reducido.
Esto suele traducirse en bajo contraste. Analizar el histograma nos permite detectar rápidamente esta situación.
A veces no interesa trabajar con frecuencias absolutas, sino con proporciones. En ese caso puede usarse un histograma normalizado, donde la suma de las frecuencias se ajusta a una escala común.
Esto es útil para:
Los histogramas también pueden utilizarse como descriptores globales de imagen. Una imagen y otra pueden compararse observando cuán similares son sus distribuciones de intensidad o color.
Esto se usa en tareas como:
Sin embargo, un histograma pierde completamente la información espacial. Dos imágenes muy diferentes pueden compartir histogramas parecidos.
Aunque el histograma es muy útil, también tiene límites importantes:
Por eso debe entenderse como una herramienta global y complementaria, no como una descripción completa de la imagen.
Un ejemplo más completo puede resolverse con una aplicación de escritorio que cargue foto1.jpg, muestre la imagen original y su versión en grises, y además incorpore gráficos embebidos para estudiar la distribución de intensidades.
En este caso, la interfaz permite analizar:
Este enfoque integra visualización, análisis numérico y gráficos en una sola ventana, lo que resulta especialmente útil para comprender el carácter global de un histograma.
import tkinter as tk
from tkinter import ttk, messagebox
import cv2
import numpy as np
from PIL import Image, ImageTk
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
class AplicacionHistogramas:
def __init__(self, root):
self.root = root
self.root.title("Histogramas y análisis de intensidades")
self.root.geometry("1450x900")
self.root.configure(bg="#f4f6f8")
self.ruta_imagen = "foto1.jpg"
self.imagen_bgr = None
self.imagen_rgb = None
self.imagen_gris = None
self.tk_original = None
self.tk_gris = None
self.crear_interfaz()
self.cargar_imagen()
# ---------------------------------------------------------
# INTERFAZ
# ---------------------------------------------------------
def crear_interfaz(self):
estilo = ttk.Style()
estilo.theme_use("clam")
estilo.configure("Titulo.TLabel", font=("Arial", 18, "bold"))
estilo.configure("Subtitulo.TLabel", font=("Arial", 11, "bold"))
estilo.configure("Info.TLabel", font=("Arial", 10))
estilo.configure("Card.TFrame", background="white")
estilo.configure("CardTitle.TLabel", background="white", font=("Arial", 11, "bold"))
estilo.configure("CardText.TLabel", background="white", font=("Arial", 10))
contenedor = ttk.Frame(self.root, padding=12)
contenedor.pack(fill="both", expand=True)
titulo = ttk.Label(
contenedor,
text="Histogramas y análisis global de intensidades",
style="Titulo.TLabel"
)
titulo.pack(anchor="center", pady=(0, 10))
subtitulo = ttk.Label(
contenedor,
text="Visualización de foto1.jpg, escala de grises e histogramas",
style="Info.TLabel"
)
subtitulo.pack(anchor="center", pady=(0, 12))
cuerpo = ttk.Frame(contenedor)
cuerpo.pack(fill="both", expand=True)
# Panel izquierdo: imágenes y datos
self.panel_izquierdo = ttk.Frame(cuerpo)
self.panel_izquierdo.pack(side="left", fill="y", padx=(0, 10))
# Panel derecho: gráficos
self.panel_derecho = ttk.Frame(cuerpo)
self.panel_derecho.pack(side="left", fill="both", expand=True)
self.crear_panel_imagenes()
self.crear_panel_graficos()
def crear_panel_imagenes(self):
marco_original = ttk.Frame(self.panel_izquierdo, style="Card.TFrame", padding=10)
marco_original.pack(fill="x", pady=(0, 10))
ttk.Label(marco_original, text="Imagen original", style="CardTitle.TLabel").pack(anchor="center", pady=(0, 8))
self.label_original = ttk.Label(marco_original, background="white")
self.label_original.pack()
marco_gris = ttk.Frame(self.panel_izquierdo, style="Card.TFrame", padding=10)
marco_gris.pack(fill="x", pady=(0, 10))
ttk.Label(marco_gris, text="Imagen en escala de grises", style="CardTitle.TLabel").pack(anchor="center", pady=(0, 8))
self.label_gris = ttk.Label(marco_gris, background="white")
self.label_gris.pack()
marco_info = ttk.Frame(self.panel_izquierdo, style="Card.TFrame", padding=10)
marco_info.pack(fill="x", pady=(0, 10))
ttk.Label(marco_info, text="Resumen estadístico", style="CardTitle.TLabel").pack(anchor="w", pady=(0, 8))
self.label_info = ttk.Label(
marco_info,
text="Cargando imagen...",
style="CardText.TLabel",
justify="left"
)
self.label_info.pack(anchor="w")
def crear_panel_graficos(self):
notebook = ttk.Notebook(self.panel_derecho)
notebook.pack(fill="both", expand=True)
self.tab_gris = ttk.Frame(notebook)
self.tab_color = ttk.Frame(notebook)
self.tab_acumulado = ttk.Frame(notebook)
notebook.add(self.tab_gris, text="Histograma en grises")
notebook.add(self.tab_color, text="Histogramas por canal")
notebook.add(self.tab_acumulado, text="Histograma acumulado")
self.crear_figura_gris()
self.crear_figura_color()
self.crear_figura_acumulada()
# ---------------------------------------------------------
# CARGA DE IMAGEN
# ---------------------------------------------------------
def cargar_imagen(self):
self.imagen_bgr = cv2.imread(self.ruta_imagen)
if self.imagen_bgr is None:
messagebox.showerror(
"Error",
"No se pudo cargar foto1.jpg\n\nVerifica que esté en la misma carpeta del programa."
)
return
self.imagen_rgb = cv2.cvtColor(self.imagen_bgr, cv2.COLOR_BGR2RGB)
self.imagen_gris = cv2.cvtColor(self.imagen_bgr, cv2.COLOR_BGR2GRAY)
self.mostrar_imagenes()
self.actualizar_estadisticas()
self.dibujar_histograma_gris()
self.dibujar_histogramas_color()
self.dibujar_histograma_acumulado()
# ---------------------------------------------------------
# MOSTRAR IMÁGENES
# ---------------------------------------------------------
def convertir_a_tk(self, imagen, ancho_max=420, alto_max=260, es_gris=False):
if es_gris:
imagen_pil = Image.fromarray(imagen)
imagen_pil = imagen_pil.convert("L")
else:
imagen_pil = Image.fromarray(imagen)
ancho, alto = imagen_pil.size
escala = min(ancho_max / ancho, alto_max / alto)
nuevo_ancho = max(1, int(ancho * escala))
nuevo_alto = max(1, int(alto * escala))
imagen_pil = imagen_pil.resize((nuevo_ancho, nuevo_alto), Image.Resampling.LANCZOS)
return ImageTk.PhotoImage(imagen_pil)
def mostrar_imagenes(self):
self.tk_original = self.convertir_a_tk(self.imagen_rgb, es_gris=False)
self.label_original.configure(image=self.tk_original)
self.tk_gris = self.convertir_a_tk(self.imagen_gris, es_gris=True)
self.label_gris.configure(image=self.tk_gris)
# ---------------------------------------------------------
# ESTADÍSTICAS
# ---------------------------------------------------------
def actualizar_estadisticas(self):
gris = self.imagen_gris
minimo = int(np.min(gris))
maximo = int(np.max(gris))
promedio = float(np.mean(gris))
desvio = float(np.std(gris))
hist = cv2.calcHist([gris], [0], None, [256], [0, 256]).flatten()
intensidad_mas_frecuente = int(np.argmax(hist))
texto = (
f"Dimensiones: {gris.shape[1]} x {gris.shape[0]}\n"
f"Intensidad mínima: {minimo}\n"
f"Intensidad máxima: {maximo}\n"
f"Promedio: {promedio:.2f}\n"
f"Desvío estándar: {desvio:.2f}\n"
f"Nivel más frecuente: {intensidad_mas_frecuente}"
)
self.label_info.configure(text=texto)
# ---------------------------------------------------------
# FIGURAS MATPLOTLIB
# ---------------------------------------------------------
def crear_figura_gris(self):
self.fig_gris = Figure(figsize=(8, 5), dpi=100)
self.ax_gris = self.fig_gris.add_subplot(111)
self.fig_gris.tight_layout(pad=3.0)
self.canvas_gris = FigureCanvasTkAgg(self.fig_gris, master=self.tab_gris)
self.canvas_gris.get_tk_widget().pack(fill="both", expand=True)
def crear_figura_color(self):
self.fig_color = Figure(figsize=(8, 5), dpi=100)
self.ax_color = self.fig_color.add_subplot(111)
self.fig_color.tight_layout(pad=3.0)
self.canvas_color = FigureCanvasTkAgg(self.fig_color, master=self.tab_color)
self.canvas_color.get_tk_widget().pack(fill="both", expand=True)
def crear_figura_acumulada(self):
self.fig_acum = Figure(figsize=(8, 5), dpi=100)
self.ax_acum = self.fig_acum.add_subplot(111)
self.fig_acum.tight_layout(pad=3.0)
self.canvas_acum = FigureCanvasTkAgg(self.fig_acum, master=self.tab_acumulado)
self.canvas_acum.get_tk_widget().pack(fill="both", expand=True)
# ---------------------------------------------------------
# DIBUJO DE HISTOGRAMAS
# ---------------------------------------------------------
def dibujar_histograma_gris(self):
self.ax_gris.clear()
hist = cv2.calcHist([self.imagen_gris], [0], None, [256], [0, 256]).flatten()
self.ax_gris.bar(range(256), hist, width=1.0)
self.ax_gris.set_title("Histograma de intensidades en escala de grises", fontsize=13, fontweight="bold")
self.ax_gris.set_xlabel("Intensidad")
self.ax_gris.set_ylabel("Cantidad de píxeles")
self.ax_gris.set_xlim(0, 255)
self.ax_gris.grid(True, alpha=0.25)
self.canvas_gris.draw()
def dibujar_histogramas_color(self):
self.ax_color.clear()
hist_b = cv2.calcHist([self.imagen_bgr], [0], None, [256], [0, 256]).flatten()
hist_g = cv2.calcHist([self.imagen_bgr], [1], None, [256], [0, 256]).flatten()
hist_r = cv2.calcHist([self.imagen_bgr], [2], None, [256], [0, 256]).flatten()
self.ax_color.plot(hist_b, color="blue", linewidth=2, label="Canal azul")
self.ax_color.plot(hist_g, color="green", linewidth=2, label="Canal verde")
self.ax_color.plot(hist_r, color="red", linewidth=2, label="Canal rojo")
self.ax_color.set_title("Histogramas por canal de color", fontsize=13, fontweight="bold")
self.ax_color.set_xlabel("Intensidad")
self.ax_color.set_ylabel("Cantidad de píxeles")
self.ax_color.set_xlim(0, 255)
self.ax_color.grid(True, alpha=0.25)
self.ax_color.legend()
self.canvas_color.draw()
def dibujar_histograma_acumulado(self):
self.ax_acum.clear()
hist = cv2.calcHist([self.imagen_gris], [0], None, [256], [0, 256]).flatten()
hist_acum = np.cumsum(hist)
self.ax_acum.plot(hist_acum, color="black", linewidth=2.5)
self.ax_acum.set_title("Histograma acumulado", fontsize=13, fontweight="bold")
self.ax_acum.set_xlabel("Intensidad")
self.ax_acum.set_ylabel("Cantidad acumulada de píxeles")
self.ax_acum.set_xlim(0, 255)
self.ax_acum.grid(True, alpha=0.25)
self.canvas_acum.draw()
if __name__ == "__main__":
root = tk.Tk()
app = AplicacionHistogramas(root)
root.mainloop()
Esta aplicación permite analizar foto1.jpg desde una única ventana, combinando imágenes, estadísticas descriptivas e histogramas embebidos para escala de grises, canales de color y distribución acumulada.
En documentos, el análisis de intensidades resulta útil para:
En este tipo de casos, CLAHE y Otsu son recursos particularmente valiosos.
En imágenes médicas o industriales, la distribución de intensidades puede dar información muy importante sobre calidad de captura y visibilidad de estructuras.
Por ejemplo:
Al trabajar con histogramas y análisis de intensidades, algunos errores frecuentes son:
La mejora visual percibida por un humano no siempre coincide con la representación más útil para un sistema automático.
Los histogramas y el análisis de intensidades ofrecen una forma muy poderosa de resumir la información visual de una imagen. Nos permiten entender su distribución tonal, evaluar contraste y decidir estrategias de mejora o segmentación.
Con este tema cerramos una primera etapa sólida de procesamiento clásico: ya vimos representación digital, espacios de color, OpenCV, operaciones matriciales, transformaciones, filtrado, bordes e histogramas.
En el próximo tema comenzaremos la transición hacia el aprendizaje profundo con una introducción a las redes convolucionales (CNN), el puente natural entre visión por computadora clásica y modelos modernos.