Módulo 7 · Visión por computadora
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ódigo
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.
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?
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())
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.
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ón | IoU con la caja real |
|---|---|
| igual | 1.000 |
| una columna | 0.500 |
| diagonal | 0.286 |
| contigua | 0.000 |
| grande | 0.250 |
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:
- ordena las candidatas de mayor a menor puntaje
- conserva la primera candidata que queda
- descarta las que tienen un IoU mayor que el umbral con una caja ya conservada
- 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.
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.
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?
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))
La salida esperada está oculta.
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.
Arrastra la caja punteada o su esquina inferior derecha.
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.
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ódigo
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.
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?
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))
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?
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))
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?
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))
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.
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ón | unión | Dice | IoU | exactitud |
|---|---|---|---|---|
| 3 | 9 | 0.500 | 0.333 | 0.625 |
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ódigo
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?
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))
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.
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),
)
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.
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:
- calcula
iou_matriz(candidatas, cajas), con las cajas reales de «Tres formatos de salida» - cuenta cuántas candidatas tienen un IoU mayor que 0.5 con alguna caja real
- reescribe
nmspara que lea una fila de la matriz en lugar de llamar aiou_caja
Reto opcional
Construye una U-Net de un nivel para las imágenes de 16×16:
- un bloque de encoder con dos convoluciones de 3×3 y
nn.MaxPool2d(2) - un bloque central de dos convoluciones de 3×3 a 8×8
nn.ConvTranspose2d(..., kernel_size=2, stride=2)para volver a 16×16torch.cat(..., dim=1)con el mapa del encoder previo al pooling- 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, mezclastex2como borde exterior conx2como ú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:
xes la columna y el píxel esimagen[y, x]. - Si
F.cross_entropylanza un error de tipo o de forma con la máscara, revisa que el objetivo tenga tipotorch.longy forma(N, H, W), sin eje de canal. - Si
metricas_mascaralanzaZeroDivisionError, 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 danan. 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_entropyen segmentación? - ¿Qué recupera el decoder de U-Net gracias a las skip connections?
Documentación oficial
- torchvision.ops.box_iou: matriz de IoU entre dos conjuntos de cajas en formato
(x1, y1, x2, y2). - torchvision.ops.nms: supresión de no máximos con puntajes y umbral de IoU.
- torch.nn.functional.cross_entropy: pérdida con entradas
(N, C, d1, d2, …)para clasificar cada posición. - sklearn.metrics.jaccard_score: índice de Jaccard entre etiquetas verdaderas y predichas.
- U-Net: Convolutional Networks for Biomedical Image Segmentation: artículo original con el encoder, el decoder y las skip connections.