La detección de anomalías con visión por computador permite identificar defectos en producto antes de que lleguen al cliente, sin necesidad de imágenes etiquetadas de defectos para entrenar el modelo. Este artículo construye el sistema completo desde cero, con código ejecutable y resultados reales.

Artículo de Fundamentos. Dataset: MVTec AD — Hazelnut. Stack: PyTorch, scikit-learn. Todo el código es ejecutable. Los resultados mostrados son reales, generados sobre el dataset completo.

El problema: calidad que depende de quien mira

Una empresa de frutos secos procesa toneladas de avellanas diariamente. Al final de la línea de producción, un inspector visual evalúa el producto: grietas, cortes, agujeros, manchas. En la primera hora del turno, el sistema funciona. En la octava, el inspector ha visto cincuenta mil avellanas y su capacidad de detección ha caído. El reporte de rechazos llega al día siguiente. Para entonces el lote está empaquetado.

El problema no es la persona. Es que el sistema de control de calidad depende de un recurso que se fatiga, que no es consistente entre turnos y que genera el dato cuando la decisión ya es tarde. El reporte es, una vez más, un retrovisor: el mismo problema que hace que la mayoría de empresas solo usen sus datos para explicar lo que ya ocurrió.

El coste invisible

Una avellana defectuosa que supera la inspección no solo genera una devolución. Genera un proceso de trazabilidad, un potencial recall de lote, y un impacto en la confianza del cliente que no aparece en ningún reporte de producción. El dato llega siempre después del daño.

La solución: una cámara que no se fatiga

La propuesta es instalar una cámara sobre la línea de producción conectada a un modelo de visión por computador que evalúa cada avellana en tiempo real. Cuando el score de anomalía supera un umbral, el sistema activa el rechazo automático antes de que el producto llegue al empaquetado.

El problema técnico inmediato es el que convierte esto en un reto real: no tenemos imágenes etiquetadas de defectos para entrenar. En producción, los defectos son raros por definición. Si el sistema funciona bien, hay muy pocas avellanas defectuosas. Y no podemos esperar a acumular miles de defectos etiquetados antes de poder desplegar el modelo. La solución clásica de clasificación supervisada no aplica aquí.

🧠

La intuición

Un inspector experimentado que lleva años viendo avellanas perfectas detecta al instante algo que no encaja, aunque nunca haya visto ese defecto exacto antes. No necesita haber visto el defecto para saber que algo está mal: sabe perfectamente cómo es lo correcto, y eso es suficiente. El autoencoder funciona exactamente igual.

El algoritmo y el pipeline

Un autoencoder convolucional es una red neuronal que aprende a comprimir una imagen en una representación compacta y luego reconstruirla. Entrenado exclusivamente sobre imágenes de avellanas correctas, el modelo aprende qué es una avellana normal. Cuando en inferencia recibe una avellana defectuosa, no puede reconstruirla bien: el error de reconstrucción es alto precisamente en la zona del defecto. Ese error es la señal de anomalía.

INPUT 128×128×3 ENCODER Conv 3→32 Conv 32→64 Conv 64→128 Conv→512 LATENT 512×8×8 bottleneck DECODER ConvT 512→256 ConvT 256→128 ConvT 128→64 Conv→3 + Tanh OUTPUT error → score

Arquitectura del autoencoder convolucional. El error entre input y output es la señal de anomalía: alto en zonas defectuosas, bajo en producto correcto.

El dataset que usamos es MVTec AD — categoría hazelnut, el benchmark estándar de detección de anomalías visuales industriales. Contiene 391 imágenes de entrenamiento de avellanas correctas y 110 imágenes de test distribuidas entre avellanas buenas y cuatro tipos de defecto: grieta (crack), corte (cut), agujero (hole) y mancha (print). Cada defecto tiene anotación pixel-precisa para evaluación.

El dataset y el preprocesamiento

Lo primero es entender con qué trabajamos. El dataset tiene una estructura limpia que separa claramente el split de entrenamiento — solo producto correcto — del split de test, que incluye tanto avellanas buenas como las cuatro categorías de defecto.

Python · exploración del dataset
train_good = DATA_ROOT / "train" / "good"
test_dirs  = {d.name: d for d in (DATA_ROOT / "test").iterdir() if d.is_dir()}

print(f"Imágenes de entrenamiento (good): {len(list(train_good.glob('*.png')))}")
print("\nImágenes de test por categoría:")
for cat, path in sorted(test_dirs.items()):
    n = len(list(path.glob('*.png')))
    print(f"  {cat:10s}: {n} imágenes")
Output
Imágenes de entrenamiento (good): 391 Imágenes de test por categoría: crack : 18 imágenes cut : 17 imágenes good : 40 imágenes hole : 18 imágenes print : 17 imágenes

Las imágenes originales son de alta resolución (~1024×1024px). Las redimensionamos a 128×128 para acelerar el entrenamiento sin perder la información necesaria para detectar los defectos. La normalización lleva los valores de pixel de [0,255] a [-1,1], que es la escala que espera la función de activación Tanh del decoder.

Python · transformaciones y dataset
# Entrenamiento: ligera augmentación para mejorar generalización
train_transform = transforms.Compose([
    transforms.Resize((IMG_SIZE, IMG_SIZE)),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomRotation(degrees=10),
    transforms.ColorJitter(brightness=0.1, contrast=0.1),
    transforms.ToTensor(),                         # [0,255] → [0,1]
    transforms.Normalize([0.5]*3, [0.5]*3),        # [0,1]   → [-1,1]
])

# Test: sin augmentación, solo resize y normalización
test_transform = transforms.Compose([
    transforms.Resize((IMG_SIZE, IMG_SIZE)),
    transforms.ToTensor(),
    transforms.Normalize([0.5]*3, [0.5]*3),
])

# Train: SOLO imágenes good — el modelo nunca ve un defecto durante el entrenamiento
train_samples = [(p, 0) for p in sorted(train_good.glob('*.png'))]

# Test: good (label=0) + todas las categorías de defecto (label=1)
test_samples = [(p, 0) for p in sorted((DATA_ROOT / "test" / "good").glob('*.png'))]
for cat in defect_categories:
    test_samples += [(p, 1) for p in sorted((DATA_ROOT / "test" / cat).glob('*.png'))]
Output
Train samples : 391 Test samples : 110 (normal: 40, defect: 70) Shape de un tensor de imagen: torch.Size([3, 128, 128]) (C x H x W)

La arquitectura del autoencoder

El encoder reduce progresivamente la resolución espacial de la imagen a través de cuatro bloques convolucionales con MaxPool, comprimiendo la representación de 3×128×128 hasta un bottleneck de 512×8×8. Cada bloque aplica BatchNorm para estabilizar el entrenamiento y LeakyReLU como activación para no perder gradiente en valores negativos. El decoder es simétrico: reconstruye la resolución original con ConvTranspose2d hasta devolver una imagen de 3×128×128 en escala [-1,1] gracias a Tanh.

Python · arquitectura ConvAutoencoder
class ConvAutoencoder(nn.Module):
    """
    Autoencoder convolucional simétrico.
    Input:  (B, 3, 128, 128)
    Latent: (B, 512, 8, 8)   → 512 mapas de características de 8x8
    Output: (B, 3, 128, 128)
    """
    def __init__(self):
        super().__init__()

        # ── Encoder: compresión progresiva ────────────────────────────────────
        self.encoder = nn.Sequential(
            # 3×128×128 → 32×64×64
            nn.Conv2d(3, 32, kernel_size=3, padding=1),
            nn.BatchNorm2d(32), nn.LeakyReLU(0.2, inplace=True),
            nn.MaxPool2d(2, 2),

            # 32×64×64 → 64×32×32
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64), nn.LeakyReLU(0.2, inplace=True),
            nn.MaxPool2d(2, 2),

            # 64×32×32 → 128×16×16
            nn.Conv2d(64, 128, kernel_size=3, padding=1),
            nn.BatchNorm2d(128), nn.LeakyReLU(0.2, inplace=True),
            nn.MaxPool2d(2, 2),

            # 128×16×16 → 256×8×8
            nn.Conv2d(128, 256, kernel_size=3, padding=1),
            nn.BatchNorm2d(256), nn.LeakyReLU(0.2, inplace=True),
            nn.MaxPool2d(2, 2),

            # 256×8×8 → 512×8×8 (bottleneck, sin reducción espacial)
            nn.Conv2d(256, 512, kernel_size=3, padding=1),
            nn.BatchNorm2d(512), nn.LeakyReLU(0.2, inplace=True),
        )

        # ── Decoder: reconstrucción progresiva ────────────────────────────────
        self.decoder = nn.Sequential(
            nn.ConvTranspose2d(512, 256, kernel_size=2, stride=2),  # ×2
            nn.BatchNorm2d(256), nn.ReLU(inplace=True),

            nn.ConvTranspose2d(256, 128, kernel_size=2, stride=2),  # ×2
            nn.BatchNorm2d(128), nn.ReLU(inplace=True),

            nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2),   # ×2
            nn.BatchNorm2d(64), nn.ReLU(inplace=True),

            nn.ConvTranspose2d(64, 32, kernel_size=2, stride=2),    # ×2
            nn.BatchNorm2d(32), nn.ReLU(inplace=True),

            # Capa final: 3 canales + Tanh para escala [-1,1]
            nn.Conv2d(32, 3, kernel_size=3, padding=1),
            nn.Tanh(),
        )

    def forward(self, x):
        return self.decoder(self.encoder(x))
Output
Parámetros totales : 2,269,187 Parámetros entrenables: 2,269,187 Input shape : torch.Size([1, 3, 128, 128]) Latent shape : torch.Size([1, 512, 8, 8]) Output shape : torch.Size([1, 3, 128, 128])

Por qué 2,2M de parámetros es razonable

Un modelo de detección supervisado preentrenado (ResNet50, EfficientNet) tiene entre 25M y 80M de parámetros. El autoencoder con 2,2M parámetros es significativamente más ligero, más rápido en inferencia y no requiere GPU para producción. Para una línea de producción con cámara industrial, eso es una ventaja operativa, no una limitación.

Entrenamiento

La función de pérdida es MSE (Mean Squared Error): penaliza la diferencia pixel a pixel entre la imagen original y la reconstruida. El optimizador es Adam con weight decay para regularización. Añadimos un scheduler que reduce el learning rate cuando la pérdida se estanca, lo que permite que el modelo siga mejorando en las últimas épocas sin divergir.

Python · configuración del entrenamiento
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5)

# Reduce LR a la mitad si la pérdida no mejora en 5 épocas consecutivas
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
    optimizer, mode='min', factor=0.5, patience=5
)

for epoch in range(1, EPOCHS + 1):
    model.train()
    epoch_loss = 0.0

    for imgs, _, _ in train_loader:
        imgs = imgs.to(DEVICE)
        optimizer.zero_grad()
        reconstructed = model(imgs)
        loss = criterion(reconstructed, imgs)  # reconstrucción vs original
        loss.backward()
        optimizer.step()
        epoch_loss += loss.item()

    avg_loss = epoch_loss / len(train_loader)
    scheduler.step(avg_loss)

    # Guardamos solo el mejor modelo
    if avg_loss < best_loss:
        best_loss = avg_loss
        torch.save(model.state_dict(), "best_autoencoder.pth")
Output — progreso de entrenamiento (50 épocas)
Época 01/50 — Loss: 0.051180 (mejor: 0.051180) Época 10/50 — Loss: 0.003457 (mejor: 0.003179) Época 20/50 — Loss: 0.002670 (mejor: 0.002258) Época 30/50 — Loss: 0.002054 (mejor: 0.001870) Época 40/50 — Loss: 0.001640 (mejor: 0.001640) Época 50/50 — Loss: 0.001510 (mejor: 0.001475) Entrenamiento completado. Mejor loss: 0.001475

Qué significa este número

Un MSE de 0.001475 sobre imágenes normalizadas en [-1,1] significa que el modelo reconstruye cada pixel con un error medio de aproximadamente 0.038 unidades. Sobre una avellana correcta, eso es ruido de fondo. Sobre una zona defectuosa, el error se dispara localemente, y esa diferencia es exactamente la señal que buscamos.

El mapa de anomalía

Una vez entrenado el modelo, la inferencia calcula el error cuadrático pixel a pixel entre la imagen original y su reconstrucción, promediado sobre los tres canales RGB. El resultado es un mapa de 128×128 valores donde cada pixel representa cuánto le costó al modelo reconstruir esa zona. Las zonas defectuosas producen errores sistemáticamente más altos.

Python · cálculo del mapa de anomalía
def compute_anomaly_score(img_tensor, model, device):
    """
    Recibe un tensor (3, H, W) normalizado en [-1, 1].
    Devuelve el mapa de error normalizado y el score global.
    """
    with torch.no_grad():
        img_tensor    = img_tensor.unsqueeze(0).to(device)  # (1,3,H,W)
        reconstructed = model(img_tensor)

    # Error cuadrático pixel a pixel, promediado sobre canales RGB
    error_map   = ((img_tensor - reconstructed) ** 2).mean(dim=1)  # (1,H,W)
    anomaly_map = error_map.squeeze().cpu().numpy()                 # (H,W)

    # Normalizamos a [0,1] para visualización
    a_min, a_max = anomaly_map.min(), anomaly_map.max()
    anomaly_map_norm = (anomaly_map - a_min) / (a_max - a_min + 1e-8)

    # Score global: MSE medio de la imagen completa
    score = float(anomaly_map.mean())

    return reconstructed.squeeze().cpu(), anomaly_map_norm, score

Lo que hace poderoso el overlay no es solo que detecte la anomalía, sino que la localiza. En un sistema de producción, eso permite no solo rechazar el producto defectuoso sino registrar qué tipo de defecto aparece con más frecuencia y en qué zona de la línea. Esa información es el dato que el sistema reactivo nunca podía producir.

Evaluación: AUROC y distribución de scores

Calculamos el AUROC (Area Under the ROC Curve) sobre el test set completo: 110 imágenes, 40 normales y 70 defectuosas. El AUROC mide qué tan bien el score de anomalía separa las dos clases independientemente del umbral elegido. Un valor de 1.0 es separación perfecta; 0.5 es aleatorio.

Output — métricas de separación
AUROC: 0.9168 Scores normales — media: 0.000874 | std: 0.000148 Scores defectos — media: 0.003241 | std: 0.003891

Por qué 0.9168 es un resultado sólido

Este AUROC se obtiene con un autoencoder simple, entrenado en CPU en menos de una hora, sobre 391 imágenes de entrenamiento, sin haber visto nunca un defecto. Métodos más sofisticados como PatchCore o WinCLIP alcanzan AUROC de 0.98+ en el mismo dataset, pero requieren preentrenamiento en ImageNet o modelos de 100M+ parámetros. El autoencoder es el punto de partida correcto para validar que el sistema funciona antes de escalar la complejidad.

El umbral de decisión: el parámetro de negocio

El umbral es donde la matemática se convierte en decisión de negocio. Define a partir de qué score una avellana se rechaza. No existe un umbral universalmente correcto: depende del coste relativo de cada tipo de error. Un falso negativo —defecto que pasa como bueno— tiene el coste de una devolución o un recall. Un falso positivo —producto bueno rechazado— tiene el coste de la merma.

Umbral
Óptimo — 0.00112
Conservador — 0.00139
Criterio
Maximiza F1
Percentil 95 de normales
Defectos detectados
59 / 70 (84%)
47 / 70 (67%)
Buenas rechazadas
6 / 40 (15%)
2 / 40 (5%)
F1 Score
0.874
0.787
Cuándo usarlo
Coste de defecto alto
Coste de merma alto

Resultados por tipo de defecto

Output — AUROC por categoría
AUROC por categoría de defecto: crack : AUROC = 0.9014 cut : AUROC = 0.8059 hole : AUROC = 0.9583 print : AUROC = 1.0000 GLOBAL : AUROC = 0.9168

La variabilidad entre defectos tiene una explicación intuitiva. Una mancha (print) es visualmente muy diferente de la textura uniforme de una avellana normal: el modelo la detecta siempre. Un corte fino (cut) puede parecerse a una variación natural de la superficie: el modelo falla en los casos más sutiles. En producción real, eso informa dónde poner la cámara, con qué resolución y con qué iluminación para maximizar la visibilidad del defecto específico que más importa controlar.

Resultados y puesta en producción

El sistema se integra en la línea conectando la salida del modelo a un actuador de rechazo. El flujo en producción es simple: la cámara captura la imagen, el modelo calcula el score en menos de 50ms, si el score supera el umbral configurado se activa el rechazo automático y se registra el evento con timestamp, imagen y score para trazabilidad.

AUROC global

0.9168

Separación entre producto normal y defectuoso sobre 110 imágenes de test

F1 Score en umbral óptimo

0.874

Balance precisión-recall con umbral 0.00112

Defectos detectados

59 / 70

84% de los defectos identificados correctamente

Parámetros del modelo

2,2M

Inferencia <50ms en CPU. No requiere GPU en producción

Print detection

1.000

AUROC perfecto en detección de manchas superficiales

Imágenes de entrenamiento

391

Solo producto correcto. Cero imágenes de defectos necesarias para entrenar

Lo que cambia con este sistema no es solo la tasa de detección. Es la naturaleza del dato que produce. El sistema reactivo generaba un reporte de rechazos al día siguiente. Este sistema genera un evento por cada avellana inspeccionada: timestamp, score, categoría predicha, imagen. Ese historial permite detectar tendencias de degradación en la línea antes de que el problema sea visible a simple vista. De retrovisor a sensor proactivo.

Framework de decisión: cuándo usar un autoencoder

Framework — Autoencoder convolucional para detección de anomalías

¿Tienes imágenes etiquetadas de defectos?

No → El autoencoder es tu punto de partida. Entrenas solo con producto correcto y empiezas a generar valor desde el primer día.

¿Los defectos son visualmente muy distintos entre sí?

Sí → El autoencoder lo detecta bien. Si los defectos son sutiles y similares a variaciones normales (como el cut en este caso), considera PatchCore o métodos con preentrenamiento.

¿Necesitas inferencia en tiempo real sin GPU?

Sí → El autoencoder con 2,2M parámetros infiere en <50ms en CPU. Apto para líneas de producción con hardware industrial estándar.

¿Necesitas localización del defecto además de detección?

Sí → El mapa de anomalía lo proporciona de forma nativa. No necesitas anotaciones adicionales ni un modelo de segmentación separado.

¿AUROC de 0.91 es suficiente para tu caso de uso?

No → Escala a PatchCore (AUROC ~0.98 en MVTec) o WinCLIP con backbone CLIP. Si además tienes imágenes etiquetadas de defectos, la detección supervisada abre otro nivel: comparativa YOLO vs Faster R-CNN →


El autoencoder convolucional no es la solución más sofisticada para detección de anomalías visuales. Es la solución más honesta: entrena con lo que tienes, produce resultados interpretables y genera el dato que el sistema reactivo nunca producía. La pregunta que me gustaría que te llevaras es esta: ¿qué procesos de tu operación dependen hoy de un inspector humano que genera el reporte cuando el producto ya ha pasado, y qué costaría poner una cámara y un modelo que lo vea en tiempo real?