Tarea 8: Clasificación Bayesiana de formas geométricas

Tarea 8: Clasificación Bayesiana de formas geométricas

Información de la Tarea

Estudiante: Andrés Cruz Chipol

Curso: Aprendizaje Automático

Fecha de entrega: 22 de Abril, 2026

Descripción de la Tarea

Fecha de entrega 22.04.2026


Captura de datos

Se tomaron 8 fotografías en diferentes ángulos, distancias e iluminaciones para evitar que el modelo memorice condiciones específicas de captura.

Muestra 1 Muestra 2 Muestra 3 Muestra 4
Muestra 5 Muestra 6 Muestra 7

Cada imagen contiene varias figuras dibujadas en la misma hoja. El pipeline de extracción nos permite visualizar los objetdos detectados e ir asignandoles una etiqueta “manualmente”.


Extracción de características

Este script hace el trabajo más delicado: tomar cada imagen, aislar la hoja blanca del fondo, segmentar las figuras y calcular un vector de características geométricas por cada una. Al terminar de procesar una figura, la muestra en pantalla y espera que el usuario presione una tecla para asignarle una etiqueta.

Características extraídas

CaracterísticaDescripción
AreaNúmero de píxeles de la figura
Log_Arealog(1 + Area) — estabiliza la escala para figuras muy distintas en tamaño
Compacidad(4π·Area) / Perímetro² — valor cercano a 1 indican formas circulares
SolidezArea / AreaConvexHull — penaliza figuras con concavidades o “mordidas”
Aspect_RatioProporción ancho/alto del bounding box
Hu1Hu7Momentos de Hu transformados en escala logarítmica (invariantes a rotación y escala)
import sys, os, cv2, numpy as np, pandas as pd, glob, math
IMG_DIR = "Tarea8ML_2"
OUTPUT_CSV = "data.csv"
MARGEN_HOJA = 5
AREA_MIN_FIGURA = 200
def calculate_hu_moments_log(moments):
"""Momentos de Hu con transformación logarítmica para mayor estabilidad."""
hu = cv2.HuMoments(moments).flatten()
for i in range(7):
if hu[i] != 0:
hu[i] = -1 * math.copysign(1.0, hu[i]) * math.log10(abs(hu[i]))
return hu
def procesar_imagen(ruta_imagen):
img_color = cv2.imread(ruta_imagen)
gray = cv2.cvtColor(img_color, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (7, 7), 0)
# Aislar la hoja blanca del fondo oscuro
_, thresh_fondo = cv2.threshold(blurred, 130, 255, cv2.THRESH_BINARY)
contornos, _ = cv2.findContours(thresh_fondo, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contorno_hoja = max(contornos, key=cv2.contourArea)
x_h, y_h, w_h, h_h = cv2.boundingRect(contorno_hoja)
x_h, y_h = x_h + MARGEN_HOJA, y_h + MARGEN_HOJA
w_h, h_h = w_h - 2*MARGEN_HOJA, h_h - 2*MARGEN_HOJA
recorte_papel = gray[y_h:y_h+h_h, x_h:x_h+w_h]
# Binarizar con Otsu + apertura morfológica para limpiar ruido
ret, binarizada = cv2.threshold(recorte_papel, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)
element = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
binarizada = cv2.morphologyEx(binarizada, cv2.MORPH_OPEN, element, iterations=2)
num, Etiquetas, stats, _ = cv2.connectedComponentsWithStats(binarizada, 8, cv2.CV_32S)
rens, cols = binarizada.shape
extracted_data = []
for i in range(1, num):
area = stats[i, cv2.CC_STAT_AREA]
if not (AREA_MIN_FIGURA < area < rens * cols * 0.5):
continue
figura_mask = np.zeros((rens, cols), dtype="uint8")
figura_mask[Etiquetas == i] = 255
moments = cv2.moments(figura_mask)
hu = calculate_hu_moments_log(moments)
contours_fig, _ = cv2.findContours(figura_mask, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
cnt = contours_fig[0]
perimeter = cv2.arcLength(cnt, True)
compactness = (4 * math.pi * area) / (perimeter**2) if perimeter > 0 else 0
hull = cv2.convexHull(cnt)
solidity = float(area) / cv2.contourArea(hull) if cv2.contourArea(hull) > 0 else 0
w_f = stats[i, cv2.CC_STAT_WIDTH]
h_f = stats[i, cv2.CC_STAT_HEIGHT]
aspect_ratio = float(w_f) / h_f if h_f > 0 else 0
# Mostrar figura y esperar etiqueta manual
x_f, y_f = stats[i, cv2.CC_STAT_LEFT], stats[i, cv2.CC_STAT_TOP]
vista = figura_mask[y_f:y_f+h_f, x_f:x_f+w_f]
cv2.imshow("Etiquetado - [p]Peq [g]Gra [u]Uni [n]Nada [q]Salir",
cv2.resize(vista, (w_f*4, h_f*4), interpolation=cv2.INTER_NEAREST))
while True:
key = cv2.waitKey(0) & 0xFF
if key == ord('p'): clase = "circuloPequeno"; break
elif key == ord('g'): clase = "CirculoGrande"; break
elif key == ord('u'): clase = "CirculosUnidos"; break
elif key == ord('n'): clase = "ninguno"; break
elif key == ord('q'): return extracted_data, True
cv2.destroyAllWindows()
if clase != "ninguno":
extracted_data.append({
'Clase': clase, 'Area': area, 'Log_Area': math.log1p(area),
'Compacidad': compactness, 'Solidez': solidity,
'Aspect_Ratio': aspect_ratio,
'Hu1': hu[0], 'Hu2': hu[1], 'Hu3': hu[2],
'Hu4': hu[3], 'Hu5': hu[4], 'Hu6': hu[5], 'Hu7': hu[6]
})
return extracted_data, False

Análisis exploratorio

El script genera dos tipos de gráficas automáticamente para todas las características del CSV: un stripplot (distribución 1D por clase) y curvas de densidad KDE.

import pandas as pd, matplotlib.pyplot as plt, seaborn as sns, math
df = pd.read_csv('data.csv')
features_all = [c for c in df.columns if c not in ['image_file', 'shape_id', 'Clase']]
cols, rows = 4, math.ceil(len(features_all) / 4)
# Stripplot — distribución puntual por clase
fig, axes = plt.subplots(rows, cols, figsize=(5*cols, 4*rows))
for i, feat in enumerate(features_all):
sns.stripplot(data=df, x=feat, y='Clase', hue='Clase',
ax=axes.flatten()[i], jitter=0.2, palette='Set1', alpha=0.6, size=4)
axes.flatten()[i].set_title(feat, fontweight='bold')
plt.savefig('scatter_comportamiento_todas.png', dpi=150)
# KDE — densidad de probabilidad por clase
fig2, axes2 = plt.subplots(rows, cols, figsize=(5*cols, 4*rows))
for i, feat in enumerate(features_all):
sns.kdeplot(data=df, x=feat, hue='Clase', ax=axes2.flatten()[i],
fill=True, common_norm=False, palette='Set1', alpha=0.5)
plt.savefig('distribuciones_completas_kde.png', dpi=150)

Distribución por característica (stripplot)

Distribución de características por clase — stripplot

Densidades de probabilidad (KDE)

Densidades de probabilidad KDE por clase

Las gráficas muestran por qué Log_Area, Compacidad y Solidez son las características más informativas: sus distribuciones por clase se separan visualmente de forma clara. Los momentos de Hu individuales se solapan demasiado, aun asi, para llegar a esta conclusion intentamos entrenar con los momentos de Hu, lo cual nos dio resultados menos esperados, por lo que nos centramos en los datos menos solapados. que resulto en un mejor entrenamiento.


Entrenamiento del clasificador Bayesiano

Con los datos analizados, el entrenamiento usa las tres características que mejor separan las clases: Solidez, Compacidad y Log_Area. Se aplica GaussianNB de scikit-learn balanceado los datos.

import pandas as pd, numpy as np
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
from sklearn.naive_bayes import GaussianNB
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
df = pd.read_csv('data.csv')
# Under-sampling para clases balanceadas
min_samples = df['Clase'].value_counts().min()
df_balanced = df.groupby('Clase').sample(n=min_samples, random_state=42).reset_index(drop=True)
features = ['Solidez', 'Compacidad', 'Log_Area']
X = df_balanced[features].values
y = df_balanced['Clase'].values
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
classifier = GaussianNB(var_smoothing=1e-06)
cv_scores = cross_val_score(classifier, X, y, cv=cv)
classifier.fit(X_train, y_train)

Resultados de entrenamiento

Precisión Validación Cruzada (CV): 91.00%
Exactitud en Entrenamiento : 94.44%
Exactitud en Prueba : 95.83%

Reporte de clasificación (conjunto de prueba)

precision recall f1-score support
CirculoGrande 1.00 1.00 1.00 8
CirculosUnidos 1.00 0.88 0.93 8
circuloPequeno 0.89 1.00 0.94 8
accuracy 0.96 24
macro avg 0.96 0.96 0.96 24
weighted avg 0.96 0.96 0.96 24

Matriz de confusión

CirculoGrandeCirculosUnidoscirculoPequeno
CirculoGrande800
CirculosUnidos071
circuloPequeno008

El único error ocurre en la clase CirculosUnidos: una muestra queda clasificada como circuloPequeno. Algo que hace sentido ya que los circulos unidos son muy pequeños.

Despues de varios intentos, este fue el mejor clasificador que pudimos alcanzar de acuerdo a las caracteristicas que nos daban las imagenes.


El clasificador Bayesiano manual

El clasificador calcula el log-posterior para cada clase usando la fórmula de verosimilitud gaussiana:

$$\log P(C_k | x) \propto \log P(C_k) + \sum_{j} \left[ -\frac{(x_j - \mu_{kj})^2}{2\sigma_{kj}^2} \right]$$

import numpy as np
clases_nombres = ['CirculoGrande', 'CirculosUnidos', 'circuloPequeno']
pys = np.array([0.3333, 0.3333, 0.3333])
lpys = np.log(pys)
# Varianzas estimadas por clase: [Solidez, Compacidad, Log_Area]
var = np.array([
[2.19e-05, 5.23e-04, 9.15e-02], # CirculoGrande
[1.89e-04, 1.42e-03, 6.85e-02], # CirculosUnidos
[3.10e-04, 2.41e-03, 8.63e-02], # circuloPequeno
])
# Medias estimadas por clase: [Solidez, Compacidad, Log_Area]
med = np.array([
[1.0063, 0.8475, 8.1125], # CirculoGrande
[0.9777, 0.7174, 7.3617], # CirculosUnidos
[1.0203, 0.8680, 6.8732], # circuloPequeno
])
def evalua(x):
"""Log-posterior para cada clase dado el vector de features x."""
prob = lpys.copy()
for i in range(len(clases_nombres)):
for j in range(len(x)):
e = x[j] - med[i][j]
prob[i] -= (e * e) / (2 * var[i][j])
return prob
def predecir(x):
return clases_nombres[np.argmax(evalua(x))]
def predecir_prob(x):
log_probs = evalua(x)
exp_probs = np.exp(log_probs - np.max(log_probs))
return exp_probs / exp_probs.sum()

Predicción en tiempo real

El cierre del pipeline es un módulo de detección en vivo. Abre la webcam, aplica exactamente el mismo preprocesamiento que el script de extracción y llama a clasificador.predecir() frame a frame.

import cv2, math, numpy as np
import modelo_bayesian_manual as clasificador
MARGEN_HOJA = 5
AREA_MIN_FIGURA = 200
cap = cv2.VideoCapture(0)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
while True:
ret, frame = cap.read()
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (7, 7), 0)
_, thresh_fondo = cv2.threshold(blurred, 130, 255, cv2.THRESH_BINARY)
contornos_h, _ = cv2.findContours(thresh_fondo, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
if contornos_h:
contorno_hoja = max(contornos_h, key=cv2.contourArea)
if cv2.contourArea(contorno_hoja) > 10000:
x_h, y_h, w_h, h_h = cv2.boundingRect(contorno_hoja)
x_m, y_m = x_h + MARGEN_HOJA, y_h + MARGEN_HOJA
w_m, h_m = w_h - 2*MARGEN_HOJA, h_h - 2*MARGEN_HOJA
roi_gray = gray[y_m:y_m+h_m, x_m:x_m+w_m]
roi_color = frame[y_m:y_m+h_m, x_m:x_m+w_m]
_, binarizada = cv2.threshold(roi_gray, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
binarizada = cv2.morphologyEx(binarizada, cv2.MORPH_OPEN, kernel, iterations=2)
contours, _ = cv2.findContours(binarizada, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
area = cv2.contourArea(contour)
if area < AREA_MIN_FIGURA: continue
perimeter = cv2.arcLength(contour, True)
if perimeter == 0: continue
log_area = math.log1p(area)
compactness = (4 * math.pi * area) / (perimeter**2)
hull_area = cv2.contourArea(cv2.convexHull(contour))
solidity = area / hull_area if hull_area > 0 else 0
prediccion = clasificador.predecir([solidity, compactness, log_area])
color = {
'CirculoGrande': (255, 0, 0),
'CirculosUnidos': (0, 0, 255),
'circuloPequeno': (0, 255, 255),
}.get(prediccion, (0, 255, 0))
x, y, w, h = cv2.boundingRect(contour)
cv2.rectangle(roi_color, (x, y), (x+w, y+h), color, 2)
cv2.putText(roi_color, prediccion, (x, y-5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
cv2.imshow("Live Bayesian Vision", frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
ClaseColor
CirculoGrandeAzul
CirculosUnidosRojo
circuloPequenoCian

Demostración en video


Conclusiones

Hemos construido un dataset propio, entrenado un clasificador Naive Bayes Gaussiano y lo hemos puesto a prueba en tiempo real. El clasificador alcanzó un 95.83% de exactitud en el conjunto de prueba y demostró funcionar correctamente en tiempo real,con algunos errores en la clasificación de los círculos unidos muy pequeños por cuestiones de las caracteristica.

Tambien en tiempo real vimos que afectan ciertos factores comom la iluminacion, la distancia a la que se encuentra la camara y el angulo de vision.

Esta tarea cierra perfectamente el ciclo de vida de un proyecto de machine learning, desde la recoleccion de datos hasta la implementacion en tiempo real.

Se concluye este curso preparandonos para abordar problemas mas complejos en el proximo curso Aprendizaje Profundo.