Módulo 7 · Visión por computadora

Lección 2 de 5

Detección y segmentación

En esta páginaEl problema

El problema

Tienes una imagen en escala de grises de 8×8 píxeles. Un píxel vale 1 si pertenece a un objeto y 0 si es fondo:

0 0 0 0 0 0 0 0
0 1 1 1 0 0 0 0
0 1 1 1 0 0 0 0
0 1 1 1 0 0 0 0
0 0 0 0 0 0 0 1
0 0 0 0 0 0 1 0
0 0 0 0 0 1 0 0
0 0 0 0 1 0 0 0

La imagen contiene dos objetos: un cuadrado de 3×3 arriba a la izquierda y una línea diagonal de cuatro píxeles abajo a la derecha. En Convoluciones y clasificación, una CNN asignaba una sola clase a cada imagen de load_digits. Esa salida no dice cuántos objetos hay ni dónde está cada uno.

Un detector de objetos devuelve, además, varias cajas que se enciman sobre el mismo objeto. Necesitas describir la posición de cada objeto, medir si una predicción acierta y quitar las cajas repetidas.

Tres formatos de salida

Las tres tareas parten de la misma imagen y difieren en lo que devuelven:

  • clasificación: una respuesta por imagen, por ejemplo qué clases aparecen
  • detección: una caja y una clase por objeto
  • segmentación: una clase por píxel, llamada máscara

Una caja se escribe como (x1, y1, x2, y2). x cuenta columnas y y cuenta filas, desde la esquina superior izquierda. (x1, y1) es la esquina superior izquierda. x2 y y2 marcan el borde derecho e inferior: la primera columna y la primera fila que ya quedan fuera. Con esa convención, el área de la caja es (x2 - x1) * (y2 - y1). En un arreglo de NumPy, el píxel de la columna x y la fila y es imagen[y, x].

La celda plegada construye la máscara de El problema.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
import numpy as np

mascara = np.zeros((8, 8), dtype=np.int64)
mascara[1:4, 1:4] = 1
for y, x in [(4, 7), (5, 6), (6, 5), (7, 4)]:
    mascara[y, x] = 2

La máscara repite la imagen de El problema con la clase de cada píxel: 0 para fondo, 1 para el cuadrado y 2 para la diagonal. mascara tiene 51 píxeles con 0, 9 con 1 y 4 con 2. La caja de cada objeto se obtiene de sus píxeles: np.nonzero devuelve las filas y las columnas de los valores distintos de cero, y la caja va del mínimo al máximo de cada una.

Python 3 · ejemplo reproducible
def caja_de(mascara, clase):
    filas, columnas = np.nonzero(mascara == clase)
    return [
        int(columnas.min()),
        int(filas.min()),
        int(columnas.max()) + 1,  # x2 es la primera columna fuera
        int(filas.max()) + 1,
    ]

La salida de clasificación se arma con .any(), que vale True si algún píxel de la máscara tiene esa clase.

Pregunta. Escribe la caja del cuadrado y la de la diagonal. Escribe también la forma de presencia, de cajas y de mascara. ¿Cuántos píxeles de la caja de la diagonal son fondo?

Python 3 · ejemplo reproducible
presencia = np.array([(mascara == clase).any() for clase in (1, 2)])
cajas = np.array([caja_de(mascara, 1), caja_de(mascara, 2)])
etiquetas = np.array([1, 2])

print(presencia, presencia.shape)
print(cajas.tolist(), cajas.shape, etiquetas)
print(mascara.shape, mascara.dtype)

x1, y1, x2, y2 = cajas[1]
print((x2 - x1) * (y2 - y1), (mascara == 2).sum())
Salida esperada

La salida esperada está oculta.

IoU de cajas

Para decidir si una caja predicha acierta, se compara con la caja real. La intersección sobre la unión (IoU) divide el área común entre el área que cubren las dos juntas:

IoU = área(A ∩ B) / (área(A) + área(B) - área(A ∩ B))

La intersección de dos cajas es otra caja. Su borde izquierdo es el mayor de los dos x1 y su borde derecho es el menor de los dos x2; lo mismo ocurre con y. Si ese ancho o ese alto sale negativo, las cajas no se tocan y el ancho o el alto vale 0. iou_caja aplica esa regla y la fórmula anterior.

Python 3 · ejemplo reproducible
def iou_caja(a, b):
    ancho = max(0, min(a[2], b[2]) - max(a[0], b[0]))
    alto = max(0, min(a[3], b[3]) - max(a[1], b[1]))
    interseccion = ancho * alto
    area_a = (a[2] - a[0]) * (a[3] - a[1])
    area_b = (b[2] - b[0]) * (b[3] - b[1])
    return interseccion / (area_a + area_b - interseccion)

Cinco cajas predichas para el cuadrado, cada una con un nombre:

  • igual: (1, 1, 4, 4)
  • una columna: (2, 1, 5, 4)
  • diagonal: (2, 2, 5, 5)
  • contigua: (4, 1, 7, 4)
  • grande: (0, 0, 6, 6)

Pregunta. La caja real es la del cuadrado, (1, 1, 4, 4). Calcula a mano el IoU de las cinco predicciones. ¿Cuál queda exactamente en 0.5?

Ver resultado

La tabla da el IoU de cada caja predicha con la caja real (1, 1, 4, 4). La primera columna es el nombre de la predicción y la segunda su IoU, entre 0 y 1. Por ejemplo, la fila «grande» con 0.250 dice que la intersección con la caja (0, 0, 6, 6) es la cuarta parte de la unión.

predicciónIoU con la caja real
igual1.000
una columna0.500
diagonal0.286
contigua0.000
grande0.250
Lectura. Mover la caja una columna deja una intersección de 2×3 = 6 píxeles y una unión de 9 + 9 - 6 = 12: el IoU cae a 0.5. Moverla una fila y una columna lo baja a 4 / 14 = 0.286. La caja contigua comparte el borde x = 4, pero ninguna columna: su IoU es 0. La caja grande contiene todo el cuadrado y aun así obtiene 0.25, porque el IoU también castiga el área de más. En un objeto de 3×3, un error de un píxel ya cambia mucho el IoU.

Una detección se suele contar como correcta según un umbral para su IoU con una caja real de la misma clase, con frecuencia 0.5. La caja «una columna» queda justo en el límite: cuenta como correcta si la regla es IoU ≥ 0.5 y no cuenta si es IoU > 0.5.

Supresión de no máximos

Un detector propone muchas cajas candidatas, cada una con un puntaje de confianza entre 0 y 1. Varias caen sobre el mismo objeto. La supresión de no máximos (en inglés, non-maximum suppression, NMS) conserva una por grupo:

  1. ordena las candidatas de mayor a menor puntaje
  2. conserva la primera candidata que queda
  3. descarta las que tienen un IoU mayor que el umbral con una caja ya conservada
  4. repite con la siguiente candidata que no se descartó

La celda usa seis candidatas para la imagen de El problema, con un puntaje cada una.

Python 3 · ejemplo reproducible
candidatas = np.array(
    [
        [1, 1, 4, 4],
        [1, 1, 5, 4],
        [4, 4, 8, 8],
        [4, 3, 8, 8],
        [2, 2, 5, 5],
        [5, 0, 8, 2],
    ]
)
puntajes = np.array([0.90, 0.75, 0.80, 0.55, 0.65, 0.30])

np.argsort devuelve los índices que ordenan un arreglo de menor a mayor. Con -puntajes, el orden queda de mayor a menor puntaje. nms recorre ese orden y conserva una candidata solo si su IoU con cada caja ya conservada no pasa del umbral.

Python 3 · ejemplo reproducible
def nms(cajas, puntajes, umbral):
    orden = np.argsort(-puntajes)
    conservadas = []
    for i in orden:
        if all(iou_caja(cajas[i], cajas[j]) <= umbral for j in conservadas):
            conservadas.append(int(i))
    return conservadas

La celda siguiente imprime el orden, tres IoU entre candidatas y el resultado con umbral 0.5.

Pregunta. Escribe el orden en que NMS revisa las candidatas y los índices que conserva. ¿Alguna caja repetida sobre el cuadrado sobrevive?

Python 3 · ejemplo reproducible
print(np.argsort(-puntajes).tolist())
print(round(iou_caja(candidatas[1], candidatas[0]), 3))
print(round(iou_caja(candidatas[3], candidatas[2]), 3))
print(round(iou_caja(candidatas[4], candidatas[0]), 3))
print(nms(candidatas, puntajes, umbral=0.5))
Salida esperada

La salida esperada está oculta.

Explorador

IoU de cajas y NMS

Arrastra la caja predicha o su esquina inferior derecha y observa cómo cambian la intersección, la unión y el IoU frente al umbral.

Qué probar

En «Supresión de no máximos», avanza paso a paso y mira qué candidatas descarta NMS.

Modo

Arrastra la caja punteada o su esquina inferior derecha.

Parámetros

Con el teclado, las flechas mueven la caja una unidad y Shift más las flechas, tres. La predicción cuenta como acierto si su IoU es mayor o igual que el umbral.

Real 42, predicha 42, intersección 30, unión 54: IoU 30 / 54 = 0.556. Alcanza el umbral 0.50: la predicción cuenta como acierto.

Segmentación por píxel

Una red de segmentación resuelve un problema de clasificación en cada píxel. Para una imagen de H × W con C clases, devuelve logits con forma (N, C, H, W): un vector de C puntajes por píxel. La máscara objetivo tiene forma (N, H, W) y tipo entero, con la clase de cada píxel. F.cross_entropy acepta esas formas y promedia la pérdida sobre todos los píxeles.

Las convoluciones con padding=1 y kernel 3×3 de Convoluciones y clasificación conservan el alto y el ancho. Una pila de ellas ya produce una salida por píxel. Los datos son imágenes sintéticas de 16×16 con un rectángulo claro y ruido gaussiano. La celda plegada los genera.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
import torch
import torch.nn.functional as F
from torch import nn


def lote_rectangulos(n, lado, generador):
    mascaras = torch.zeros(n, lado, lado, dtype=torch.long)
    for k in range(n):
        alto, ancho = torch.randint(3, 8, (2,), generator=generador).tolist()
        y1 = torch.randint(0, lado - alto + 1, (1,), generator=generador).item()
        x1 = torch.randint(0, lado - ancho + 1, (1,), generator=generador).item()
        mascaras[k, y1 : y1 + alto, x1 : x1 + ancho] = 1
    ruido = 0.6 * torch.randn(n, lado, lado, generator=generador)
    imagenes = (mascaras.float() + ruido).unsqueeze(1)
    return imagenes, mascaras


generador = torch.Generator().manual_seed(0)
x_ent, m_ent = lote_rectangulos(64, 16, generador)
x_prueba, m_prueba = lote_rectangulos(32, 16, generador)

x_ent tiene forma (64, 1, 16, 16) y x_prueba tiene forma (32, 1, 16, 16). Hay 64 imágenes de entrenamiento y 32 de prueba, con un canal de 16×16 cada una. Cada rectángulo mide entre 3 y 7 píxeles de alto y de ancho, en una posición al azar. La imagen vale 1 en el rectángulo y 0 en el fondo, más ruido con desviación estándar 0.6. Las máscaras m_ent y m_prueba guardan 1 en los píxeles del rectángulo y 0 en el fondo.

El segmentador apila dos convoluciones. torch.manual_seed(0) fija sus pesos iniciales.

Python 3 · ejemplo reproducible
torch.manual_seed(0)
segmentador = nn.Sequential(
    nn.Conv2d(1, 8, kernel_size=3, padding=1),
    nn.ReLU(),
    nn.Conv2d(8, 2, kernel_size=3, padding=1),  # 2 clases: fondo y rectángulo
)

logits.argmax(dim=1) toma, en cada píxel, el índice del canal con el logit mayor.

Pregunta. Escribe la forma de logits y la de logits.argmax(dim=1). ¿Qué fracción aproximada de los píxeles de entrenamiento es rectángulo?

Python 3 · ejemplo reproducible
logits = segmentador(x_ent)

print(m_ent.shape, m_ent.dtype)
print(logits.shape)
print(logits.argmax(dim=1).shape)
print(round(m_ent.float().mean().item(), 3))
Salida esperada

La salida esperada está oculta.

El entrenamiento repite el ciclo zero_grad, backward, step de M5, pero con el lote completo de entrenamiento en cada paso, sin DataLoader.

Pregunta. Con dos clases, una red que aún no distingue nada da una pérdida cercana a ln 2 ≈ 0.693. ¿La pérdida inicial queda cerca de ese valor?

Python 3 · ejemplo reproducible
optimizador = torch.optim.Adam(segmentador.parameters(), lr=0.02)

for paso in range(201):
    perdida = F.cross_entropy(segmentador(x_ent), m_ent)
    optimizador.zero_grad()
    perdida.backward()
    optimizador.step()
    if paso % 100 == 0:
        print(paso, round(perdida.item(), 3))
Salida esperada

La salida esperada está oculta.

La evaluación en prueba va dentro de torch.no_grad(), que no registra gradientes.

Pregunta. Después de entrenar, ¿la exactitud por píxel en prueba queda por encima o por debajo de la fracción de fondo?

Python 3 · ejemplo reproducible
with torch.no_grad():
    pred_prueba = segmentador(x_prueba).argmax(dim=1)

print(pred_prueba.shape, pred_prueba.dtype)
print(round((pred_prueba == m_prueba).float().mean().item(), 3))
print(round(1 - m_prueba.float().mean().item(), 3))
Salida esperada

La salida esperada está oculta.

Dos convoluciones de 3×3 ven un vecindario de 5×5 alrededor de cada píxel. Un objeto grande necesita más contexto, sin perder la posición exacta de sus bordes. U-Net combina las dos cosas con tres partes:

  • encoder: bloques de convolución seguidos de max pooling. Cada bloque reduce el alto y el ancho a la mitad y duplica los canales. En el artículo original hay cuatro reducciones y los canales pasan de 64 a 1024.
  • decoder: bloques que duplican el alto y el ancho con una convolución transpuesta y reducen los canales a la mitad.
  • skip connections: en cada resolución, el decoder concatena por el eje de canales el mapa del encoder de la misma resolución. Así recupera el detalle de los bordes que se perdió al reducir.

Una convolución 1×1 final convierte los canales en C logits por píxel. En la variante habitual, con padding=1 en las convoluciones de 3×3, el decoder recupera la resolución de entrada y la salida es la misma (N, C, H, W) de esta sección. El artículo original usa convoluciones sin padding: recorta los mapas del encoder antes de concatenarlos, y su salida de 388×388 es menor que la entrada de 572×572. U-Net se publicó en 2015 para segmentar imágenes biomédicas.

Dice e IoU de máscaras

El IoU también se calcula entre máscaras. La intersección son los píxeles que las dos máscaras marcan como objeto. La unión son los píxeles que marca al menos una. El coeficiente Dice divide el doble de la intersección entre la suma de los píxeles de objeto de las dos máscaras:

Dice = 2 · |A ∩ B| / (|A| + |B|)

Dice e IoU usan la misma intersección con distinto denominador.

metricas_mascara convierte las dos máscaras a booleanos. Con booleanos, & marca los píxeles de la intersección y | los de la unión.

Python 3 · ejemplo reproducible
def metricas_mascara(verdad, predicha):
    verdad = np.asarray(verdad).astype(bool)  # cualquier clase distinta de 0 es objeto
    predicha = np.asarray(predicha).astype(bool)
    interseccion = int((verdad & predicha).sum())
    union = int((verdad | predicha).sum())
    dice = 2 * interseccion / int(verdad.sum() + predicha.sum())
    iou = interseccion / union
    exactitud = float((verdad == predicha).mean())
    return interseccion, union, dice, iou, exactitud

Una máscara real de 4×4, con 1 en el objeto y 0 en el fondo:

0 1 1 0
0 1 1 0
0 1 1 0
0 0 0 0

Pregunta. La máscara predicha es la real desplazada una columna a la derecha. Calcula a mano la intersección, la unión, Dice, IoU y la exactitud por píxel.

Ver resultado

La tabla compara la máscara real con la predicha. La predicha marca las columnas 2 y 3 de las filas 0, 1 y 2. Las columnas de la tabla son los píxeles que las dos marcan como objeto (intersección), los que marca al menos una (unión), Dice, IoU y la fracción de los 16 píxeles en que coinciden (exactitud). Por ejemplo, la intersección 3 dice que solo 3 píxeles son objeto en las dos máscaras.

intersecciónuniónDiceIoUexactitud
390.5000.3330.625
Lectura. Las dos máscaras tienen 6 píxeles de objeto y comparten 3. La unión tiene 9 píxeles, así que IoU = 3 / 9 = 0.333 y Dice = 6 / 12 = 0.5. Para la misma predicción, Dice nunca es menor que IoU. La exactitud por píxel es 10 / 16 = 0.625, porque también cuenta los 7 píxeles de fondo en que coinciden.

Ahora las métricas se aplican al segmentador. Se suman todos los píxeles de las 32 imágenes de prueba. La referencia es una máscara que predice fondo en todos los píxeles, creada con np.zeros_like. La función plegada mostrar imprime las cinco métricas de metricas_mascara en una línea con un nombre.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
def mostrar(nombre, metricas):
    interseccion, union, dice, iou, exactitud = metricas
    print(
        f"{nombre:10} inter={interseccion:4} union={union:4} "
        f"dice={dice:.3f} iou={iou:.3f} exactitud={exactitud:.3f}"
    )

Pregunta. Para la máscara «todo fondo», ¿qué exactitud por píxel, qué Dice y qué IoU esperas?

Python 3 · ejemplo reproducible
verdad_prueba = m_prueba.numpy()
todo_fondo = np.zeros_like(verdad_prueba)

mostrar("modelo", metricas_mascara(verdad_prueba, pred_prueba.numpy()))
mostrar("todo fondo", metricas_mascara(verdad_prueba, todo_fondo))
Salida esperada

La salida esperada está oculta.

Con una sola clase de objeto, Dice es el F1 de esa clase y el IoU de máscaras es el índice de Jaccard. La celda siguiente aplana las máscaras con ravel() y las pasa a f1_score y jaccard_score de scikit-learn, que comparan etiquetas píxel a píxel.

Python 3 · ejemplo reproducible
from sklearn.metrics import f1_score, jaccard_score

verdad_plana = verdad_prueba.ravel()
predicha_plana = pred_prueba.numpy().ravel()
print(
    round(f1_score(verdad_plana, predicha_plana), 3),
    round(jaccard_score(verdad_plana, predicha_plana), 3),
)
Salida esperada

La salida esperada está oculta.

Los dos valores coinciden con el Dice y el IoU del modelo.

Práctica

Traza guiada

Para la caja real (2, 0, 6, 3) y la caja predicha (3, 1, 7, 5), escribe antes de procesar el área de cada caja, la caja de intersección, su área, la unión y el IoU. Después dibuja una máscara de 8×8 con un objeto en forma de L y escribe la caja que devolvería caja_de.

Python 3 · ejemplo reproducible
a = (2, 0, 6, 3)
b = (3, 1, 7, 5)

Comprueba con iou_caja(a, b) y con caja_de. Si el IoU no coincide, revisa si contaste x2 como una columna del objeto.

Umbral de supresión

Repite nms sobre candidatas con umbral 0.25 y con umbral 0.8. Antes de procesar, predice qué índices conserva cada umbral. Después conserva solo las candidatas con puntaje de al menos 0.6 y aplica NMS con umbral 0.5. Traduce cada índice del resultado a su índice en candidatas. Comprueba con assert que cada índice traducido tiene en puntajes un valor de al menos 0.6.

Matriz de IoU

Escribe iou_matriz(cajas_a, cajas_b). Debe recibir arreglos de formas (N, 4) y (M, 4) y devolver un arreglo (N, M) con broadcasting, sin ciclos. Comprueba con np.allclose que cada entrada coincide con iou_caja al comparar candidatas contra sí mismas. Después:

  1. calcula iou_matriz(candidatas, cajas), con las cajas reales de «Tres formatos de salida»
  2. cuenta cuántas candidatas tienen un IoU mayor que 0.5 con alguna caja real
  3. reescribe nms para que lea una fila de la matriz en lugar de llamar a iou_caja

Reto opcional

Construye una U-Net de un nivel para las imágenes de 16×16:

  1. un bloque de encoder con dos convoluciones de 3×3 y nn.MaxPool2d(2)
  2. un bloque central de dos convoluciones de 3×3 a 8×8
  3. nn.ConvTranspose2d(..., kernel_size=2, stride=2) para volver a 16×16
  4. torch.cat(..., dim=1) con el mapa del encoder previo al pooling
  5. una convolución 1×1 con 2 canales de salida

Antes de entrenar, predice la forma del tensor después de cada paso. Entrena con los mismos datos y compara su Dice de prueba con el de segmentador.

Errores que revisar

  • Si un IoU sale negativo o mayor que 1, falta max(0, …) en el ancho o el alto de la intersección.
  • Si dos cajas que solo comparten un borde dan un IoU distinto de 0, o el área lleva un + 1, mezclaste x2 como borde exterior con x2 como última columna del objeto.
  • Si la caja de un objeto sale reflejada respecto a la diagonal o fuera de la imagen, intercambiaste filas y columnas: x es la columna y el píxel es imagen[y, x].
  • Si F.cross_entropy lanza un error de tipo o de forma con la máscara, revisa que el objetivo tenga tipo torch.long y forma (N, H, W), sin eje de canal.
  • Si metricas_mascara lanza ZeroDivisionError, las dos máscaras están vacías: el denominador de Dice y la unión valen 0. Con arreglos de NumPy o tensores, la misma división da nan. Fija antes de evaluar qué valor recibe ese caso.

Cierre

Explica por qué la máscara «todo fondo» obtiene una exactitud por píxel de 0.902 y un Dice de 0.

Lista de comprobación

  • ¿Qué información da la máscara de la diagonal que su caja no da?
  • ¿Por qué una caja que contiene por completo al objeto puede tener un IoU bajo?
  • ¿Por qué la caja 4 sobrevive a NMS con umbral 0.5 aunque se superpone con el mismo cuadrado que la caja 0?
  • ¿Qué forma tienen los logits y la máscara objetivo que recibe F.cross_entropy en segmentación?
  • ¿Qué recupera el decoder de U-Net gracias a las skip connections?

Documentación oficial

Cargando progreso…