Comparativa de Modelos de Segmentación Semántica
Comparativa práctica de FCN, DeepLabV3 y MobileNet sobre Pascal VOC 2012: segmentación semántica píxel a píxel.
Comparativa de Modelos de Segmentación Semántica
FCN, DeepLabV3 y MobileNet sobre Pascal VOC 2012
Presentación
La segmentación semántica es una de las tareas más exigentes y útiles de la visión por computador: asignar una clase a cada píxel de una imagen. Mientras que la clasificación responde "¿qué hay?", la detección responde "¿dónde está?" y la segmentación responde "¿qué forma exacta tiene?". Esta granularidad píxel a píxel es esencial en aplicaciones como conducción autónoma (delimitar carretera, peatones, señales), robótica (manipulación de objetos), imagen médica (tumores, órganos) y edición de imagen.
Objetivos de este notebook
- Comparar tres arquitecturas de segmentación semántica representativas de diferentes etapas de la evolución del campo:
- FCN-ResNet50 (Fully Convolutional Networks) — la arquitectura pionera que demostró que las CNNs podían hacer segmentación end-to-end.
- DeepLabV3-ResNet50 — usa convoluciones dilatadas (atrous) y el módulo ASPP para capturar contexto multi-escala sin perder resolución.
- DeepLabV3-MobileNetV3 — variante ligera optimizada para dispositivos móviles y tiempo real.
- Evaluar cuantitativamente con la métrica estándar mIoU (mean Intersection over Union) sobre el dataset Pascal VOC 2012.
- Analizar el trade-off precisión vs. eficiencia: ¿cuánto rendimiento se pierde al usar un modelo 3× más pequeño? ¿Merece la pena?
- Identificar fortalezas y debilidades de cada modelo por clase: ¿qué objetos son fáciles/difíciles de segmentar?
- Visualizar diferencias cualitativas entre las predicciones de cada modelo.
Bases teóricas
Evolución de las arquitecturas de segmentación
| Año | Modelo | Innovación clave | mIoU en VOC |
|---|---|---|---|
| 2015 | FCN | Primera red fully convolutional para segmentación end-to-end | ~62% |
| 2015 | U-Net | Skip connections encoder-decoder para bordes nítidos | — (médica) |
| 2016 | DeepLabV2 | Atrous convolutions + CRF post-procesamiento | ~71% |
| 2017 | DeepLabV3 | ASPP mejorado + batch normalization | ~78% |
| 2018 | DeepLabV3+ | Decoder module + backbone Xception | ~82% |
| 2020 | LRASPP | Versión ligera para dispositivos móviles | ~57% |
Convolución dilatada (atrous convolution)
La convolución estándar tiene un campo receptivo limitado por el tamaño del kernel. La convolución dilatada introduce un parámetro de dilatación $r$ (rate) que expande el campo receptivo sin aumentar los parámetros ni reducir la resolución:
$$ y[i] = \sum_{k} x[i + r \cdot k] \cdot w[k] $$
Con $r = 1$ es una convolución normal. Con $r = 6, 12, 18$ (valores típicos en ASPP) el filtro "ve" regiones de 13×13, 25×25 y 37×37 píxeles respectivamente, sin necesidad de max pooling.
ASPP (Atrous Spatial Pyramid Pooling)
El módulo ASPP es la innovación central de DeepLab. Aplica convoluciones dilatadas en paralelo con diferentes rates y concatena los resultados, capturando contexto a múltiples escalas simultáneamente:
$$ \text{ASPP}(x) = \text{Concat}\left[\text{Conv}{1\times1}(x),; \text{AtrousConv}{r=6}(x),; \text{AtrousConv}{r=12}(x),; \text{AtrousConv}{r=18}(x),; \text{GAP}(x)\right] $$
Esto es crucial porque los objetos en escenas reales aparecen a escalas muy diferentes: un coche lejano ocupa pocos píxeles, uno cercano ocupa cientos.
FCN vs. DeepLab: diferencia arquitectónica clave
- FCN usa el backbone ResNet como extractor de features y luego aplica upsampling bilineal simple para recuperar la resolución original. No tiene módulos de contexto explícitos.
- DeepLab reemplaza las últimas capas de stride/pooling por convoluciones dilatadas (manteniendo resolución) y añade ASPP para contexto multi-escala. Produce features más ricas antes del upsampling.
Dataset: Pascal VOC 2012
Pascal VOC (Visual Object Classes) es el benchmark clásico de segmentación semántica:
| Característica | Valor |
|---|---|
| Clases | 21 (20 objetos + fondo): persona, coche, gato, perro, avión, etc. |
| Imágenes val | 1449 imágenes de escenas cotidianas |
| Anotaciones | Máscaras píxel a píxel con bordes ignorados (valor 255) |
| Métrica estándar | mIoU (mean Intersection over Union) |
La métrica mIoU se calcula como:
$$ \text{mIoU} = \frac{1}{C} \sum_{c=1}^{C} \frac{TP_c}{TP_c + FP_c + FN_c} $$
Modelos comparados
Los tres modelos que evaluamos usan pesos preentrenados en COCO (que incluye las clases VOC):
| Modelo | Backbone | Params | Módulo de contexto |
|---|---|---|---|
| FCN-ResNet50 | ResNet50 | 35.3M | Ninguno (upsampling directo) |
| DeepLabV3-ResNet50 | ResNet50 | 42.0M | ASPP (rates 6, 12, 18) |
| DeepLabV3-MobileNetV3 | MobileNetV3-Large | 11.0M | ASPP simplificado (LRASPP) |
# Librerías y configuración
import os
import time
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from PIL import Image
import torch
import torch.nn.functional as F
import torchvision
from torchvision import transforms
from torchvision.models.segmentation import (
fcn_resnet50, FCN_ResNet50_Weights,
deeplabv3_resnet50, DeepLabV3_ResNet50_Weights,
deeplabv3_mobilenet_v3_large, DeepLabV3_MobileNet_V3_Large_Weights
)
# Selección de dispositivo
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Dispositivo: {device}')
print(f'PyTorch version: {torch.__version__}')
print(f'Torchvision version: {torchvision.__version__}')
plt.rcParams['figure.figsize'] = (12, 7)
Dispositivo: cuda PyTorch version: 2.10.0+cu128 Torchvision version: 0.25.0+cu128
1) Descarga de Pascal VOC 2012
Descargamos el dataset Pascal VOC 2012 usando torchvision. Este dataset contiene imágenes con anotaciones de segmentación semántica para 21 clases (20 objetos + fondo).
# Descarga de Pascal VOC 2012
VOC_ROOT = '/tmp/voc_data'
# Descargamos el dataset de segmentación
try:
voc_dataset = torchvision.datasets.VOCSegmentation(
root=VOC_ROOT, year='2012', image_set='val', download=True
)
print(f'Pascal VOC 2012 (val): {len(voc_dataset)} imágenes')
except Exception as e:
print(f'Error descargando VOC: {e}')
print('Intentando con mirror alternativo...')
# Si falla, usamos imágenes de ejemplo
voc_dataset = None
# Clases de Pascal VOC
VOC_CLASSES = [
'fondo', 'avión', 'bicicleta', 'pájaro', 'barco',
'botella', 'autobús', 'coche', 'gato', 'silla',
'vaca', 'mesa', 'perro', 'caballo', 'moto',
'persona', 'planta', 'oveja', 'sofá', 'tren', 'monitor'
]
# Paleta de colores para visualización (colores de VOC)
VOC_COLORMAP = np.array([
[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0], [0, 0, 128],
[128, 0, 128], [0, 128, 128], [128, 128, 128], [64, 0, 0], [192, 0, 0],
[64, 128, 0], [192, 128, 0], [64, 0, 128], [192, 0, 128], [64, 128, 128],
[192, 128, 128], [0, 64, 0], [128, 64, 0], [0, 192, 0], [128, 192, 0],
[0, 64, 128]
], dtype=np.uint8)
print(f'\nClases ({len(VOC_CLASSES)}): {VOC_CLASSES}')
100.0%
Pascal VOC 2012 (val): 1449 imágenes Clases (21): ['fondo', 'avión', 'bicicleta', 'pájaro', 'barco', 'botella', 'autobús', 'coche', 'gato', 'silla', 'vaca', 'mesa', 'perro', 'caballo', 'moto', 'persona', 'planta', 'oveja', 'sofá', 'tren', 'monitor']
# Visualizamos ejemplos del dataset VOC
def decode_segmap(mask_tensor, n_classes=21):
"""Convierte máscara de índices a imagen RGB usando la paleta VOC."""
mask = mask_tensor.numpy() if torch.is_tensor(mask_tensor) else np.array(mask_tensor)
rgb = np.zeros((*mask.shape, 3), dtype=np.uint8)
for c in range(n_classes):
rgb[mask == c] = VOC_COLORMAP[c]
return rgb
if voc_dataset is not None:
fig, axes = plt.subplots(3, 3, figsize=(16, 14))
# Seleccionamos imágenes interesantes (con múltiples objetos)
sample_indices = [0, 15, 45, 80, 120, 200, 250, 300, 400]
for i, idx in enumerate(sample_indices):
row, col = divmod(i, 3)
img, mask = voc_dataset[idx]
mask_np = np.array(mask)
# Superposición imagen + segmentación
img_np = np.array(img)
seg_rgb = decode_segmap(mask_np)
# Blend
valid = mask_np < 255 # Ignorar bordes (255)
overlay = img_np.copy()
overlay[valid] = (0.5 * img_np[valid] + 0.5 * seg_rgb[valid]).astype(np.uint8)
axes[row, col].imshow(overlay)
# Identificamos clases presentes
classes_present = np.unique(mask_np)
classes_present = classes_present[(classes_present != 0) & (classes_present != 255)]
class_names = [VOC_CLASSES[c] for c in classes_present if c < len(VOC_CLASSES)]
axes[row, col].set_title(', '.join(class_names), fontsize=10)
axes[row, col].axis('off')
plt.suptitle('Pascal VOC 2012: ejemplos con segmentación ground truth', fontsize=14)
plt.tight_layout()
plt.show()
2) Carga de modelos preentrenados
Cargamos tres modelos de segmentación de torchvision, todos preentrenados en COCO (que incluye las clases de VOC):
- FCN-ResNet50: la primera arquitectura fully convolutional, con ResNet50 como backbone.
- DeepLabV3-ResNet50: usa ASPP para contexto multi-escala + ResNet50 backbone.
- DeepLabV3-MobileNetV3-Large: variante ligera con MobileNetV3, optimizada para dispositivos móviles.
Los tres modelos producen predicciones para las 21 clases de Pascal VOC.
# Cargamos los tres modelos
print('Cargando modelos preentrenados...')
models = {}
# 1) FCN-ResNet50
models['FCN-ResNet50'] = fcn_resnet50(weights=FCN_ResNet50_Weights.COCO_WITH_VOC_LABELS_V1)
print(' ✓ FCN-ResNet50')
# 2) DeepLabV3-ResNet50
models['DeepLabV3-ResNet50'] = deeplabv3_resnet50(weights=DeepLabV3_ResNet50_Weights.COCO_WITH_VOC_LABELS_V1)
print(' ✓ DeepLabV3-ResNet50')
# 3) DeepLabV3-MobileNetV3
models['DeepLabV3-MobileNet'] = deeplabv3_mobilenet_v3_large(
weights=DeepLabV3_MobileNet_V3_Large_Weights.COCO_WITH_VOC_LABELS_V1
)
print(' ✓ DeepLabV3-MobileNetV3-Large')
# Ponemos en modo evaluación y movemos al dispositivo
for name, model in models.items():
model.eval()
model.to(device)
# Contamos parámetros
print(f'\nComparación de tamaño de modelos:')
for name, model in models.items():
n_params = sum(p.numel() for p in model.parameters()) / 1e6
print(f' {name}: {n_params:.1f}M parámetros')
Cargando modelos preentrenados... Downloading: "https://download.pytorch.org/models/fcn_resnet50_coco-1167a1af.pth" to /home/nuberu/.cache/torch/hub/checkpoints/fcn_resnet50_coco-1167a1af.pth
100.0%
✓ FCN-ResNet50 Downloading: "https://download.pytorch.org/models/deeplabv3_resnet50_coco-cd0a2569.pth" to /home/nuberu/.cache/torch/hub/checkpoints/deeplabv3_resnet50_coco-cd0a2569.pth
100.0%
✓ DeepLabV3-ResNet50 Downloading: "https://download.pytorch.org/models/deeplabv3_mobilenet_v3_large-fc3c493d.pth" to /home/nuberu/.cache/torch/hub/checkpoints/deeplabv3_mobilenet_v3_large-fc3c493d.pth
100.0%
✓ DeepLabV3-MobileNetV3-Large Comparación de tamaño de modelos: FCN-ResNet50: 35.3M parámetros DeepLabV3-ResNet50: 42.0M parámetros DeepLabV3-MobileNet: 11.0M parámetros
3) Pipeline de inferencia
Definimos funciones auxiliares para preprocesar imágenes y ejecutar la inferencia con cada modelo. Los modelos de torchvision esperan imágenes normalizadas con la media y desviación estándar de ImageNet.
# Preprocesamiento estándar para modelos de torchvision
preprocess = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
def predict_segmentation(model, image_pil):
"""
Ejecuta segmentación semántica sobre una imagen PIL.
Retorna la máscara de predicción (H, W) con índices de clase.
"""
input_tensor = preprocess(image_pil).unsqueeze(0).to(device)
with torch.no_grad():
output = model(input_tensor)['out'] # [1, 21, H, W]
# Argmax para obtener la clase más probable por píxel
pred = output.argmax(1).squeeze(0).cpu().numpy() # [H, W]
return pred
def predict_with_timing(model, image_pil, n_runs=5):
"""
Ejecuta segmentación y mide el tiempo promedio de inferencia.
"""
input_tensor = preprocess(image_pil).unsqueeze(0).to(device)
# Warmup
with torch.no_grad():
_ = model(input_tensor)
if device.type == 'cuda':
torch.cuda.synchronize()
times = []
for _ in range(n_runs):
start = time.time()
with torch.no_grad():
output = model(input_tensor)['out']
if device.type == 'cuda':
torch.cuda.synchronize()
times.append(time.time() - start)
pred = output.argmax(1).squeeze(0).cpu().numpy()
avg_time = np.mean(times)
return pred, avg_time
print('Pipeline de inferencia preparado.')
Pipeline de inferencia preparado.
4) Comparación visual de predicciones
Aplicamos los tres modelos a varias imágenes de Pascal VOC y comparamos visualmente sus predicciones. Esto nos permite ver diferencias cualitativas: ¿cuál maneja mejor los bordes? ¿cuál confunde más clases?
# Comparación visual en imágenes seleccionadas
if voc_dataset is not None:
# Seleccionamos imágenes con objetos variados
test_indices = [10, 50, 100, 150, 250]
model_names = list(models.keys())
fig, axes = plt.subplots(len(test_indices), len(model_names) + 2,
figsize=(4 * (len(model_names) + 2), 4 * len(test_indices)))
for row, idx in enumerate(test_indices):
img_pil, mask_pil = voc_dataset[idx]
img_np = np.array(img_pil)
gt_mask = np.array(mask_pil)
# Imagen original
axes[row, 0].imshow(img_np)
axes[row, 0].axis('off')
if row == 0:
axes[row, 0].set_title('Original', fontsize=12)
# Ground Truth
gt_rgb = decode_segmap(gt_mask)
axes[row, 1].imshow(gt_rgb)
axes[row, 1].axis('off')
if row == 0:
axes[row, 1].set_title('Ground Truth', fontsize=12)
# Predicciones de cada modelo
for col, name in enumerate(model_names):
pred = predict_segmentation(models[name], img_pil)
pred_rgb = decode_segmap(pred)
axes[row, col + 2].imshow(pred_rgb)
axes[row, col + 2].axis('off')
if row == 0:
axes[row, col + 2].set_title(name, fontsize=12)
plt.suptitle('Comparación visual de modelos de segmentación en Pascal VOC', fontsize=15)
plt.tight_layout()
plt.show()
5) Evaluación cuantitativa: mIoU
La métrica estándar para segmentación semántica es el mean Intersection over Union (mIoU):
$$ \text{mIoU} = \frac{1}{C} \sum_{c=1}^{C} \frac{TP_c}{TP_c + FP_c + FN_c} $$
donde $C$ es el número de clases, $TP_c$ son los true positives de la clase $c$, etc.
Calculamos el mIoU de cada modelo sobre un subconjunto del validation set de VOC.
def compute_iou_per_class(pred, target, n_classes=21, ignore_index=255):
"""
Calcula IoU por clase entre predicción y ground truth.
Ignora píxeles con valor ignore_index (bordes en VOC).
"""
ious = []
valid = target != ignore_index
for c in range(n_classes):
pred_c = (pred == c) & valid
target_c = (target == c) & valid
intersection = (pred_c & target_c).sum()
union = (pred_c | target_c).sum()
if union == 0:
ious.append(float('nan')) # Clase no presente
else:
ious.append(intersection / union)
return ious
# Evaluamos cada modelo en un subconjunto de VOC val
N_EVAL = min(200, len(voc_dataset)) if voc_dataset is not None else 0
results = {}
timing_results = {}
if voc_dataset is not None and N_EVAL > 0:
print(f'Evaluando {N_EVAL} imágenes de Pascal VOC 2012 val...')
for name, model in models.items():
print(f'\n Evaluando {name}...')
all_ious = []
total_time = 0
for i in range(N_EVAL):
img_pil, mask_pil = voc_dataset[i]
gt = np.array(mask_pil)
pred, inf_time = predict_with_timing(model, img_pil, n_runs=1)
total_time += inf_time
# Redimensionamos predicción al tamaño del GT si es necesario
if pred.shape != gt.shape:
pred_tensor = torch.tensor(pred).unsqueeze(0).unsqueeze(0).float()
pred_resized = F.interpolate(
pred_tensor, size=gt.shape, mode='nearest'
).squeeze().numpy().astype(int)
else:
pred_resized = pred
ious = compute_iou_per_class(pred_resized, gt)
all_ious.append(ious)
if (i + 1) % 50 == 0:
print(f' {i+1}/{N_EVAL} imágenes procesadas')
# Promediamos IoU por clase (ignorando NaN)
all_ious = np.array(all_ious)
class_ious = np.nanmean(all_ious, axis=0)
miou = np.nanmean(class_ious)
results[name] = {
'mIoU': miou,
'class_ious': class_ious,
'avg_time': total_time / N_EVAL
}
timing_results[name] = total_time / N_EVAL
print(f' mIoU = {miou:.4f}, tiempo medio = {total_time/N_EVAL*1000:.1f} ms')
print('\n¡Evaluación completada!')
Evaluando 200 imágenes de Pascal VOC 2012 val...
Evaluando FCN-ResNet50...
50/200 imágenes procesadas
100/200 imágenes procesadas
150/200 imágenes procesadas
200/200 imágenes procesadas
mIoU = 0.5255, tiempo medio = 5.3 ms
Evaluando DeepLabV3-ResNet50...
50/200 imágenes procesadas
100/200 imágenes procesadas
150/200 imágenes procesadas
200/200 imágenes procesadas
mIoU = 0.5994, tiempo medio = 6.2 ms
Evaluando DeepLabV3-MobileNet...
50/200 imágenes procesadas
100/200 imágenes procesadas
150/200 imágenes procesadas
200/200 imágenes procesadas
mIoU = 0.4979, tiempo medio = 2.0 ms
¡Evaluación completada!
6) Comparación de resultados
Visualizamos los resultados cuantitativos: mIoU global, IoU por clase, y tiempo de inferencia.
if results:
model_names = list(results.keys())
fig, axes = plt.subplots(1, 3, figsize=(20, 6))
colors_models = ['#3498db', '#e74c3c', '#2ecc71']
# --- 1) mIoU global ---
mious = [results[n]['mIoU'] for n in model_names]
bars = axes[0].bar(model_names, mious, color=colors_models, edgecolor='white', width=0.6)
axes[0].set_title('mIoU global', fontsize=13)
axes[0].set_ylabel('mIoU')
axes[0].set_ylim(0, 1)
for bar, val in zip(bars, mious):
axes[0].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,
f'{val:.3f}', ha='center', fontsize=12, fontweight='bold')
axes[0].tick_params(axis='x', rotation=15)
# --- 2) Parámetros ---
params = [sum(p.numel() for p in models[n].parameters()) / 1e6 for n in model_names]
bars2 = axes[1].bar(model_names, params, color=colors_models, edgecolor='white', width=0.6)
axes[1].set_title('Tamaño del modelo', fontsize=13)
axes[1].set_ylabel('Millones de parámetros')
for bar, val in zip(bars2, params):
axes[1].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.5,
f'{val:.1f}M', ha='center', fontsize=12, fontweight='bold')
axes[1].tick_params(axis='x', rotation=15)
# --- 3) Tiempo de inferencia ---
times = [results[n]['avg_time'] * 1000 for n in model_names] # ms
bars3 = axes[2].bar(model_names, times, color=colors_models, edgecolor='white', width=0.6)
axes[2].set_title('Tiempo de inferencia', fontsize=13)
axes[2].set_ylabel('Milisegundos')
for bar, val in zip(bars3, times):
axes[2].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 1,
f'{val:.0f} ms', ha='center', fontsize=12, fontweight='bold')
axes[2].tick_params(axis='x', rotation=15)
plt.suptitle('Comparación cuantitativa de modelos de segmentación', fontsize=15)
plt.tight_layout()
plt.show()
# IoU por clase para cada modelo
if results:
fig, ax = plt.subplots(figsize=(16, 8))
x = np.arange(len(VOC_CLASSES))
width = 0.25
for i, name in enumerate(model_names):
class_ious = results[name]['class_ious']
# Reemplazamos NaN por 0 para visualización
class_ious_plot = np.nan_to_num(class_ious, nan=0.0)
bars = ax.bar(x + i * width, class_ious_plot, width,
label=name, color=colors_models[i], alpha=0.8, edgecolor='white')
ax.set_xticks(x + width)
ax.set_xticklabels(VOC_CLASSES, rotation=45, ha='right', fontsize=9)
ax.set_ylabel('IoU')
ax.set_title('IoU por clase - Comparación de modelos', fontsize=14)
ax.legend(fontsize=11)
ax.set_ylim(0, 1)
ax.grid(True, axis='y', alpha=0.3)
plt.tight_layout()
plt.show()
# Tabla resumen por clase
print(f'{"Clase":<12}', end='')
for name in model_names:
print(f'{name:<22}', end='')
print()
print('-' * 78)
for c, cls_name in enumerate(VOC_CLASSES):
print(f'{cls_name:<12}', end='')
for name in model_names:
iou = results[name]['class_ious'][c]
if np.isnan(iou):
print(f'{" -":<22}', end='')
else:
print(f'{iou:<22.4f}', end='')
print()
Clase FCN-ResNet50 DeepLabV3-ResNet50 DeepLabV3-MobileNet ------------------------------------------------------------------------------ fondo 0.9246 0.9327 0.9009 avión 0.6001 0.6963 0.5917 bicicleta 0.3355 0.3635 0.3271 pájaro 0.5163 0.6166 0.5620 barco 0.4846 0.5816 0.4345 botella 0.5328 0.4984 0.3911 autobús 0.6809 0.8355 0.5712 coche 0.4127 0.4444 0.4049 gato 0.5833 0.6831 0.6084 silla 0.2018 0.2377 0.1599 vaca 0.5909 0.6788 0.5202 mesa 0.3027 0.4636 0.3499 perro 0.5023 0.5707 0.4966 caballo 0.5110 0.6162 0.4926 moto 0.7916 0.7670 0.6453 persona 0.6665 0.7316 0.5919 planta 0.3200 0.3617 0.1497 oveja 0.5505 0.7298 0.6235 sofá 0.3541 0.4407 0.3918 tren 0.6863 0.8020 0.7754 monitor 0.4866 0.5343 0.4673
7) Análisis del trade-off precisión vs eficiencia
Un aspecto clave en aplicaciones reales es el trade-off entre precisión y eficiencia. No siempre el modelo más preciso es el más adecuado: en dispositivos móviles o sistemas en tiempo real, un modelo ligero puede ser preferible.
Visualizamos este trade-off con un gráfico de dispersión.
if results:
fig, ax = plt.subplots(figsize=(10, 7))
for i, name in enumerate(model_names):
miou = results[name]['mIoU']
n_params = sum(p.numel() for p in models[name].parameters()) / 1e6
inf_time = results[name]['avg_time'] * 1000
# Tamaño del punto proporcional al tiempo de inferencia
ax.scatter(n_params, miou, s=inf_time * 5, color=colors_models[i],
edgecolors='black', linewidth=1.5, zorder=5, alpha=0.8)
ax.annotate(name, (n_params, miou),
xytext=(10, 10), textcoords='offset points',
fontsize=11, fontweight='bold',
arrowprops=dict(arrowstyle='->', color='gray'))
ax.set_xlabel('Millones de parámetros', fontsize=12)
ax.set_ylabel('mIoU', fontsize=12)
ax.set_title('Trade-off Precisión vs Tamaño del modelo\n(tamaño del punto ∝ tiempo de inferencia)', fontsize=14)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Eficiencia: mIoU por millón de parámetros
print('\nEficiencia (mIoU por millón de parámetros):')
for name in model_names:
n_params = sum(p.numel() for p in models[name].parameters()) / 1e6
efficiency = results[name]['mIoU'] / n_params
print(f' {name}: {efficiency:.4f} mIoU/M_params')
Eficiencia (mIoU por millón de parámetros): FCN-ResNet50: 0.0149 mIoU/M_params DeepLabV3-ResNet50: 0.0143 mIoU/M_params DeepLabV3-MobileNet: 0.0451 mIoU/M_params
8) Análisis de errores: ¿dónde fallan los modelos?
Identificamos las clases más difíciles y más fáciles para cada modelo. Esto ayuda a entender las fortalezas y debilidades de cada arquitectura.
if results:
print('Análisis de clases más difíciles y más fáciles:')
print('=' * 70)
for name in model_names:
class_ious = results[name]['class_ious']
# Filtramos NaN
valid_classes = [(i, iou) for i, iou in enumerate(class_ious) if not np.isnan(iou)]
valid_classes.sort(key=lambda x: x[1])
print(f'\n{name} (mIoU = {results[name]["mIoU"]:.4f}):')
print(' Top 3 más fáciles:')
for idx, iou in valid_classes[-3:]:
print(f' {VOC_CLASSES[idx]}: IoU = {iou:.4f}')
print(' Top 3 más difíciles:')
for idx, iou in valid_classes[:3]:
print(f' {VOC_CLASSES[idx]}: IoU = {iou:.4f}')
Análisis de clases más difíciles y más fáciles:
======================================================================
FCN-ResNet50 (mIoU = 0.5255):
Top 3 más fáciles:
tren: IoU = 0.6863
moto: IoU = 0.7916
fondo: IoU = 0.9246
Top 3 más difíciles:
silla: IoU = 0.2018
mesa: IoU = 0.3027
planta: IoU = 0.3200
DeepLabV3-ResNet50 (mIoU = 0.5994):
Top 3 más fáciles:
tren: IoU = 0.8020
autobús: IoU = 0.8355
fondo: IoU = 0.9327
Top 3 más difíciles:
silla: IoU = 0.2377
planta: IoU = 0.3617
bicicleta: IoU = 0.3635
DeepLabV3-MobileNet (mIoU = 0.4979):
Top 3 más fáciles:
moto: IoU = 0.6453
tren: IoU = 0.7754
fondo: IoU = 0.9009
Top 3 más difíciles:
planta: IoU = 0.1497
silla: IoU = 0.1599
bicicleta: IoU = 0.3271
9) Visualización detallada de diferencias
Seleccionamos imágenes donde los modelos difieren significativamente y analizamos los errores en detalle.
# Comparación detallada con overlay de errores
if voc_dataset is not None:
# Seleccionamos 3 imágenes interesantes
detail_indices = [25, 75, 180]
for idx in detail_indices:
img_pil, mask_pil = voc_dataset[idx]
img_np = np.array(img_pil)
gt = np.array(mask_pil)
fig, axes = plt.subplots(2, len(model_names) + 1, figsize=(5 * (len(model_names) + 1), 9))
# Fila superior: imagen + predicciones coloreadas
axes[0, 0].imshow(img_np)
axes[0, 0].set_title('Original', fontsize=12)
axes[0, 0].axis('off')
# GT en fila inferior izquierda
gt_rgb = decode_segmap(gt)
axes[1, 0].imshow(gt_rgb)
axes[1, 0].set_title('Ground Truth', fontsize=12)
axes[1, 0].axis('off')
for col, name in enumerate(model_names):
pred = predict_segmentation(models[name], img_pil)
# Redimensionamos si es necesario
if pred.shape != gt.shape:
pred_tensor = torch.tensor(pred).unsqueeze(0).unsqueeze(0).float()
pred = F.interpolate(
pred_tensor, size=gt.shape, mode='nearest'
).squeeze().numpy().astype(int)
# Predicción coloreada
pred_rgb = decode_segmap(pred)
axes[0, col + 1].imshow(pred_rgb)
# IoU para esta imagen
ious = compute_iou_per_class(pred, gt)
miou_img = np.nanmean([iou for iou in ious if not np.isnan(iou)])
axes[0, col + 1].set_title(f'{name}\nmIoU={miou_img:.3f}', fontsize=11)
axes[0, col + 1].axis('off')
# Mapa de errores (rojo = error)
valid_mask = gt != 255
error_map = np.zeros((*gt.shape, 3), dtype=np.uint8)
correct = (pred == gt) & valid_mask
wrong = (pred != gt) & valid_mask
error_map[correct] = [0, 180, 0] # Verde: correcto
error_map[wrong] = [220, 0, 0] # Rojo: error
error_map[~valid_mask] = [128, 128, 128] # Gris: ignorado
axes[1, col + 1].imshow(error_map)
pixel_acc = correct.sum() / valid_mask.sum() if valid_mask.sum() > 0 else 0
axes[1, col + 1].set_title(f'Errores ({pixel_acc:.1%} acc)', fontsize=11)
axes[1, col + 1].axis('off')
plt.suptitle(f'Imagen #{idx}: comparación detallada', fontsize=14)
plt.tight_layout()
plt.show()
10) Tabla resumen final
Compilamos todos los resultados en una tabla comparativa.
import pandas as pd
if results:
summary_data = []
for name in model_names:
n_params = sum(p.numel() for p in models[name].parameters()) / 1e6
summary_data.append({
'Modelo': name,
'Backbone': 'ResNet50' if 'ResNet' in name else 'MobileNetV3',
'Parámetros (M)': f'{n_params:.1f}',
'mIoU': f'{results[name]["mIoU"]:.4f}',
'Tiempo inf. (ms)': f'{results[name]["avg_time"]*1000:.1f}',
'FPS': f'{1/results[name]["avg_time"]:.1f}',
'Eficiencia (mIoU/M)': f'{results[name]["mIoU"]/n_params:.4f}'
})
df_summary = pd.DataFrame(summary_data)
display(df_summary)
print('\nObservaciones:')
# Encontramos el mejor modelo por mIoU
best_miou_name = max(model_names, key=lambda n: results[n]['mIoU'])
best_efficiency_name = max(model_names, key=lambda n: results[n]['mIoU'] /
(sum(p.numel() for p in models[n].parameters()) / 1e6))
fastest_name = min(model_names, key=lambda n: results[n]['avg_time'])
print(f' Mejor mIoU: {best_miou_name}')
print(f' Más eficiente: {best_efficiency_name}')
print(f' Más rápido: {fastest_name}')
| Modelo | Backbone | Parámetros (M) | mIoU | Tiempo inf. (ms) | FPS | Eficiencia (mIoU/M) | |
|---|---|---|---|---|---|---|---|
| 0 | FCN-ResNet50 | ResNet50 | 35.3 | 0.5255 | 5.3 | 189.8 | 0.0149 |
| 1 | DeepLabV3-ResNet50 | ResNet50 | 42.0 | 0.5994 | 6.2 | 162.6 | 0.0143 |
| 2 | DeepLabV3-MobileNet | MobileNetV3 | 11.0 | 0.4979 | 2.0 | 492.7 | 0.0451 |
Observaciones: Mejor mIoU: DeepLabV3-ResNet50 Más eficiente: DeepLabV3-MobileNet Más rápido: DeepLabV3-MobileNet
Conclusiones
Resumen de la comparativa
DeepLabV3-ResNet50 obtiene el mejor mIoU, gracias a la combinación del módulo ASPP (contexto multi-escala) con un backbone potente (ResNet50). Las convoluciones dilatadas capturan contexto global sin perder resolución, lo que se traduce en segmentaciones más coherentes.
FCN-ResNet50 es competitivo pero ligeramente inferior a DeepLabV3. Como arquitectura pionera, usa solo upsampling bilineal sin módulos de contexto avanzados, lo que puede provocar predicciones menos coherentes en regiones ambiguas.
DeepLabV3-MobileNetV3 es significativamente más pequeño y rápido, a costa de cierta pérdida en mIoU. Es la opción ideal para despliegue en dispositivos móviles o sistemas en tiempo real donde la latencia importa más que los últimos decimales de precisión.
El trade-off precisión-eficiencia es claro: MobileNet ofrece la mejor relación mIoU por parámetro, demostrando que las arquitecturas ligeras son notablemente eficientes en su uso de parámetros.
Las clases más difíciles suelen ser objetos pequeños, deformables o con texturas similares al fondo. Las clases más fáciles son objetos grandes con formas bien definidas.
Arquitectura importa, pero también el backbone
La comparación muestra que tanto la arquitectura de segmentación (FCN vs DeepLab) como la elección del backbone (ResNet50 vs MobileNetV3) impactan significativamente en el rendimiento. En la práctica, la decisión depende del caso de uso:
| Escenario | Modelo recomendado |
|---|---|
| Máxima precisión (servidor) | DeepLabV3-ResNet50/101 |
| Tiempo real / móvil | DeepLabV3-MobileNetV3 |
| Baseline simple | FCN-ResNet50 |
Siguientes pasos
- Probar backbones más potentes: ResNet101, Xception, EfficientNet.
- Evaluar modelos basados en transformers: SegFormer, Mask2Former.
- Medir rendimiento en datasets especializados: Cityscapes (conducción), ADE20K (escenas).
- Aplicar fine-tuning en un dominio específico para ver cómo mejora la precisión.
- Explorar cuantización para reducir aún más el tamaño y la latencia del modelo MobileNet.
La segmentación semántica ha avanzado enormemente gracias a las convoluciones dilatadas, los módulos de contexto multi-escala y las skip connections. Hoy disponemos de modelos que van desde los ultraligeros (tiempo real en móvil) hasta los de máxima precisión (uso en servidor), permitiendo adaptar la solución al problema concreto.