Detectar y segmentar: dónde está cada cosa

Tema 27 · Fase 6, Ver, oír y crear · Toda la ruta

Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.

Los 23 bloques de código de la teoría, en el mismo orden. Si prefieres armar tu propio cuaderno, cópialos y córrelos en orden; en la ficha Teoría está la explicación de cada uno.

Clasificar, detectar y segmentar

import urllib.request
from collections import Counter
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import torch
from PIL import Image

torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"

COMMONS = "https://upload.wikimedia.org/wikipedia/commons/"
CALLE = "Avenida_Faria_Lima%2C_cruce_de_peatones.jpg"
VIDEO = ("Congested_traffic_on_the_Dan_Ryan_Expy_"
         "%2810x_timelapse%29_-_April_2026.webm")


def bajar(nombre, url):
    """Baja un archivo a «datos» si todavía no está y devuelve su ruta."""
    ruta = Path("datos") / nombre
    if not ruta.exists():
        ruta.parent.mkdir(parents=True, exist_ok=True)
        pedido = urllib.request.Request(
            url, headers={"User-Agent": "ruta-obia/1.0 (iabolivia.org)"})
        with urllib.request.urlopen(pedido) as respuesta:
            ruta.write_bytes(respuesta.read())
    return ruta


ruta_calle = bajar("imagenes-ruta/t27-calle.jpg",
                   f"{COMMONS}thumb/9/97/{CALLE}/960px-{CALLE}")
ruta_video = bajar("video-trafico/dan-ryan-480p.webm",
                   f"{COMMONS}transcoded/6/67/{VIDEO}/{VIDEO}.480p.vp9.webm")

foto = Image.open(ruta_calle).convert("RGB")
print("Tamaño de la foto (ancho, alto):", foto.size)

Cajas: dos formas de escribirlas

def xyxy_a_xywh(c):
    x1, y1, x2, y2 = c
    return np.array([x1, y1, x2 - x1, y2 - y1])


def xywh_a_xyxy(c):
    x, y, w, h = c
    return np.array([x, y, x + w, y + h])


# Una caja alrededor del auto plateado de la izquierda
caja = np.array([251, 536, 348, 629])
print("xyxy:", caja)
print("xywh:", xyxy_a_xywh(caja))
print("y de vuelta:", xywh_a_xyxy(xyxy_a_xywh(caja)))

IoU: cuánto se parecen dos cajas

def area(c):
    # Si la caja está «al revés» (no hay intersección), el área es 0
    return max(0, c[2] - c[0]) * max(0, c[3] - c[1])


def iou(a, b):
    """IoU de dos cajas en formato xyxy."""
    interseccion = area([max(a[0], b[0]), max(a[1], b[1]),
                         min(a[2], b[2]), min(a[3], b[3])])
    union = area(a) + area(b) - interseccion
    return interseccion / union


a = [0, 0, 10, 10]
b = [5, 0, 15, 10]     # la misma caja corrida 5 a la derecha
c = [20, 20, 30, 30]   # lejos de las dos
print(f"IoU(a, a) = {iou(a, a):.3f}")
print(f"IoU(a, b) = {iou(a, b):.3f}")
print(f"IoU(a, c) = {iou(a, c):.3f}")
from torchvision.ops import box_iou


def iou_matriz(A, B):
    """IoU de cada caja de A (n x 4) con cada caja de B (m x 4)."""
    x1 = np.maximum(A[:, None, 0], B[None, :, 0])
    y1 = np.maximum(A[:, None, 1], B[None, :, 1])
    x2 = np.minimum(A[:, None, 2], B[None, :, 2])
    y2 = np.minimum(A[:, None, 3], B[None, :, 3])
    interseccion = np.clip(x2 - x1, 0, None) * np.clip(y2 - y1, 0, None)
    area_a = (A[:, 2] - A[:, 0]) * (A[:, 3] - A[:, 1])
    area_b = (B[:, 2] - B[:, 0]) * (B[:, 3] - B[:, 1])
    return interseccion / (area_a[:, None] + area_b[None, :] - interseccion)


cajas = np.array([a, b, c], dtype=float)
print(np.round(iou_matriz(cajas, cajas), 3))
print("¿Igual que torchvision?", np.allclose(
    iou_matriz(cajas, cajas), box_iou(torch.tensor(cajas),
                                      torch.tensor(cajas)).numpy()))
fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
for eje, t in zip(ejes.flat, [0.3, 0.5, 0.7, 0.9]):
    lado = np.sqrt(200 * t / (1 + t))     # lado de la intersección
    d = 10 - lado
    fija, corrida = [0, 0, 10, 10], [d, d, 10 + d, 10 + d]
    eje.add_patch(plt.Rectangle((d, d), lado, lado, color="C2", alpha=0.5))
    for c in [fija, corrida]:
        eje.add_patch(plt.Rectangle((c[0], c[1]), 10, 10, fill=False,
                                    edgecolor="C0", linewidth=2))
    eje.set_title(f"IoU = {iou(fija, corrida):.1f}")
    eje.set_xlim(-1, 16)
    eje.set_ylim(16, -1)
    eje.set_aspect("equal")
    eje.axis("off")
plt.show()

Tres detectores en la misma foto

from torchvision.models import detection
from torchvision.transforms.functional import to_tensor

pesos_frcnn = detection.FasterRCNN_MobileNet_V3_Large_320_FPN_Weights.DEFAULT
frcnn = detection.fasterrcnn_mobilenet_v3_large_320_fpn(weights=pesos_frcnn)
frcnn = frcnn.to(device).eval()
pesos_ssd = detection.SSDLite320_MobileNet_V3_Large_Weights.DEFAULT
ssd = detection.ssdlite320_mobilenet_v3_large(weights=pesos_ssd)
ssd = ssd.to(device).eval()
# Los nombres de las clases de COCO, en inglés
CLASES = pesos_frcnn.meta["categories"]


def detectar(modelo, foto, umbral=0.5):
    """Cajas (xyxy, en píxeles), clases y puntajes por encima del umbral."""
    # Los modelos de torchvision reciben una lista de imágenes 0-1
    x = to_tensor(foto).to(device)
    with torch.no_grad():
        salida = modelo([x])[0]
    deja = salida["scores"] >= umbral
    cajas = salida["boxes"][deja].cpu().numpy()
    clases = [CLASES[i] for i in salida["labels"][deja].tolist()]
    return cajas, clases, salida["scores"][deja].cpu().numpy()


resultados = {}
for nombre, modelo in [("SSDlite", ssd), ("Faster R-CNN", frcnn)]:
    resultados[nombre] = detectar(modelo, foto)
    millones = sum(p.numel() for p in modelo.parameters()) / 1e6
    cajas, clases, puntajes = resultados[nombre]
    print(f"{nombre} ({millones:.1f} M parámetros): {len(cajas)} objetos",
          dict(Counter(clases)))
from transformers import AutoImageProcessor, AutoModelForObjectDetection

DETR = "facebook/detr-resnet-50"
procesador = AutoImageProcessor.from_pretrained(DETR)
detr = AutoModelForObjectDetection.from_pretrained(DETR).to(device).eval()


def detectar_detr(foto, umbral=0.7):
    """Lo mismo que detectar(), pero con DETR."""
    x = procesador(images=foto, return_tensors="pt").to(device)
    with torch.no_grad():
        salida = detr(**x)
    r = procesador.post_process_object_detection(
        salida, threshold=umbral, target_sizes=[foto.size[::-1]])[0]
    clases = [detr.config.id2label[i] for i in r["labels"].tolist()]
    return r["boxes"].cpu().numpy(), clases, r["scores"].cpu().numpy()


resultados["DETR"] = detectar_detr(foto)
millones = sum(p.numel() for p in detr.parameters()) / 1e6
cajas, clases, puntajes = resultados["DETR"]
print(f"DETR ({millones:.1f} M parámetros): {len(cajas)} objetos")
for clase, n in Counter(clases).most_common():
    print(f"  {clase}: {n}")
def dibujar_cajas(eje, cajas, color="C0", ancho=1.5):
    for x1, y1, x2, y2 in cajas:
        # Un borde blanco debajo para que la caja se vea en cualquier fondo
        for c, a in [("white", ancho + 1.5), (color, ancho)]:
            eje.add_patch(plt.Rectangle((x1, y1), x2 - x1, y2 - y1,
                                        fill=False, edgecolor=c,
                                        linewidth=a))


fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
ejes[0, 0].imshow(foto)
ejes[0, 0].set_title("Foto")
for eje, nombre in zip(ejes.flat[1:], resultados):
    eje.imshow(foto)
    dibujar_cajas(eje, resultados[nombre][0], ancho=1)
    eje.set_title(f"{nombre}: {len(resultados[nombre][0])}")
for eje in ejes.flat:
    eje.axis("off")
plt.show()

Supresión de no máximos (NMS)

frcnn.roi_heads.nms_thresh = 1.0
crudas, clases_crudas, puntajes_crudos = detectar(frcnn, foto)
frcnn.roi_heads.nms_thresh = 0.5          # el valor que trae
print(len(crudas), "cajas con puntaje de 0,5 o más antes de NMS")
def nms(cajas, puntajes, umbral_iou=0.5):
    """Índices de las cajas que sobreviven, de mayor a menor puntaje."""
    orden = list(np.argsort(-puntajes))     # de mayor a menor
    quedan = []
    while orden:
        mejor = orden.pop(0)
        quedan.append(mejor)
        # Sobreviven solo las que no se parecen demasiado a la mejor
        orden = [i for i in orden
                 if iou(cajas[mejor], cajas[i]) <= umbral_iou]
    return quedan


from torchvision.ops import nms as nms_torchvision

mias = nms(crudas, puntajes_crudos)
suyas = nms_torchvision(torch.tensor(crudas), torch.tensor(puntajes_crudos),
                        iou_threshold=0.5).tolist()
print("Quedan:", len(mias), "cajas:", [clases_crudas[i] for i in mias])
print("¿Igual que torchvision?", [int(i) for i in mias] == suyas)
fig, ejes = plt.subplots(2, 1, figsize=(4.8, 4.8))
for eje, cajas, titulo in [(ejes[0], crudas, "Antes de NMS: 15 cajas"),
                           (ejes[1], crudas[mias], "Después de NMS: 6")]:
    eje.imshow(foto)
    dibujar_cajas(eje, cajas, ancho=1)
    # Solo la parte de la foto donde están las cajas
    eje.set_xlim(220, 580)
    eje.set_ylim(790, 490)
    eje.set_title(titulo)
    eje.axis("off")
plt.show()

DETR: detectar sin NMS

x = procesador(images=foto, return_tensors="pt").to(device)
with torch.no_grad():
    salida = detr(**x)
print("Imagen que entra:", tuple(x["pixel_values"].shape))
print("Clases por consulta:", tuple(salida.logits.shape))
print("Cajas por consulta:", tuple(salida.pred_boxes.shape))

# La última de las 92 columnas es «no hay objeto»
probabilidades = salida.logits.softmax(-1)[0]
nada = probabilidades[:, -1]
mejor_clase = probabilidades[:, :-1].max(dim=-1).values
print("Consultas que dicen «no hay objeto»:", int((nada >= 0.5).sum()))
print("Consultas con una clase de 0,7 o más:",
      int((mejor_clase >= 0.7).sum()))
from torchvision.ops import batched_nms

for nombre, (cajas, clases, puntajes) in [
        ("Faster R-CNN sin NMS", (crudas, clases_crudas, puntajes_crudos)),
        ("DETR", resultados["DETR"])]:
    # batched_nms necesita las clases como números
    codigos = torch.tensor([CLASES.index(c) for c in clases])
    quedan = batched_nms(torch.tensor(cajas), torch.tensor(puntajes),
                         codigos, iou_threshold=0.5)
    print(f"{nombre}: {len(cajas)} cajas, NMS deja {len(quedan)}")

El umbral de confianza

cajas, clases, puntajes = detectar_detr(foto, umbral=0.3)
for umbral in [0.3, 0.5, 0.7, 0.9]:
    deja = puntajes >= umbral
    personas = sum(c == "person" for c, d in zip(clases, deja) if d)
    print(f"Umbral {umbral}: {deja.sum():2d} objetos, {personas} personas")

Video: una foto tras otra

import cv2

VEHICULOS = {"car", "bus", "truck"}
video = cv2.VideoCapture(str(ruta_video))
fps = video.get(cv2.CAP_PROP_FPS)
tiempos, conteos = [], []
i = 0
while True:
    ok, cuadro = video.read()
    if not ok:                           # se acabó el video
        break
    if i % 10 == 0:
        rgb = cv2.cvtColor(cuadro, cv2.COLOR_BGR2RGB)
        cajas, clases, _ = detectar(frcnn, Image.fromarray(rgb))
        es_vehiculo = [c in VEHICULOS for c in clases]
        tiempos.append(i / fps)
        conteos.append(sum(es_vehiculo))
        if i == 180:                     # un cuadro para la figura
            ejemplo, cajas_ejemplo = rgb, cajas[es_vehiculo]
    i += 1
video.release()

print(f"{i} cuadros a {fps:.0f} por segundo ({i / fps:.1f} s); "
      f"analizamos {len(conteos)}")
print("Vehículos por cuadro: mínimo", min(conteos), "máximo", max(conteos),
      "mediana", np.median(conteos))
fig, ejes = plt.subplots(2, 1, figsize=(4.8, 4.8))
ejes[0].imshow(ejemplo)
dibujar_cajas(ejes[0], cajas_ejemplo, ancho=1)
ejes[0].set_title(f"Cuadro de los {180 / fps:.0f} s")
ejes[0].axis("off")
ejes[1].plot(tiempos, conteos, color="C0", marker="o", markersize=3)
ejes[1].plot(180 / fps, conteos[18], "o", color="C1", markersize=8)
ejes[1].set_xlabel("Tiempo (s)")
ejes[1].set_ylabel("Vehículos")
ejes[1].set_ylim(0, None)
plt.show()

Segmentar: una clase por píxel

LADO = 48


def generar_formas(n, rng):
    """n imágenes de 48 x 48 con círculos y cuadrados, y sus máscaras."""
    yy, xx = np.mgrid[0:LADO, 0:LADO]
    imagenes = np.zeros((n, LADO, LADO), dtype=np.float32)
    mascaras = np.zeros((n, LADO, LADO), dtype=np.int64)
    for i in range(n):
        # Fondo oscuro con ruido
        imagen = rng.uniform(0, 0.4) + rng.normal(0, 0.08, (LADO, LADO))
        for _ in range(rng.integers(1, 4)):        # de 1 a 3 formas
            clase = rng.integers(1, 3)             # 1 círculo, 2 cuadrado
            r = rng.uniform(4, 9)
            cy, cx = rng.uniform(r, LADO - r, size=2)
            if clase == 1:
                dentro = (yy - cy) ** 2 + (xx - cx) ** 2 <= r ** 2
            else:
                dentro = ((abs(yy - cy) <= 0.85 * r)
                          & (abs(xx - cx) <= 0.85 * r))
            # Cada forma con su propio brillo, también con ruido
            imagen[dentro] = (rng.uniform(0.5, 1)
                              + rng.normal(0, 0.08, dentro.sum()))
            mascaras[i][dentro] = clase
        imagenes[i] = imagen
    return imagenes, mascaras


rng = np.random.default_rng(42)
X_ent, Y_ent = generar_formas(1000, rng)
X_val, Y_val = generar_formas(200, rng)
print("Imágenes:", X_ent.shape, " máscaras:", Y_ent.shape)
for clase, nombre in enumerate(["fondo", "círculo", "cuadrado"]):
    print(f"Píxeles de {nombre}: {(Y_ent == clase).mean():.1%}")
from matplotlib.colors import LinearSegmentedColormap, ListedColormap

blanco_tinta = LinearSegmentedColormap.from_list("bt", ["white", "C0"])
# Fondo, círculo y cuadrado, cada uno con su color
colores_mascara = ListedColormap(["C3", "C0", "C2"])

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
for fila, k in enumerate([0, 1]):
    ejes[fila, 0].imshow(X_ent[k], cmap=blanco_tinta, vmin=0, vmax=1)
    ejes[fila, 1].imshow(Y_ent[k], cmap=colores_mascara, vmin=0, vmax=2)
ejes[0, 0].set_title("Imagen")
ejes[0, 1].set_title("Máscara")
for eje in ejes.flat:
    eje.axis("off")
plt.show()

La U-Net

from torch import nn
from torch.utils.data import DataLoader, TensorDataset


def doble_conv(entrada, salida):
    """Dos convoluciones 3 x 3, cada una con BatchNorm y ReLU."""
    return nn.Sequential(
        nn.Conv2d(entrada, salida, 3, padding=1), nn.BatchNorm2d(salida),
        nn.ReLU(),
        nn.Conv2d(salida, salida, 3, padding=1), nn.BatchNorm2d(salida),
        nn.ReLU())


class UNet(nn.Module):
    def __init__(self, clases=3):
        super().__init__()
        self.bajar = nn.MaxPool2d(2)
        # Codificador: 48 x 48, 24 x 24 y 12 x 12
        self.cod1 = doble_conv(1, 8)
        self.cod2 = doble_conv(8, 16)
        self.centro = doble_conv(16, 32)
        # Decodificador: sube y junta lo del codificador
        self.subir2 = nn.ConvTranspose2d(32, 16, 2, stride=2)
        self.dec2 = doble_conv(16 + 16, 16)
        self.subir1 = nn.ConvTranspose2d(16, 8, 2, stride=2)
        self.dec1 = doble_conv(8 + 8, 8)
        self.salida = nn.Conv2d(8, clases, 1)

    def forward(self, x):
        c1 = self.cod1(x)                       # (8, 48, 48)
        c2 = self.cod2(self.bajar(c1))          # (16, 24, 24)
        centro = self.centro(self.bajar(c2))    # (32, 12, 12)
        # Conexiones de salto: se pegan los canales del codificador
        d2 = self.dec2(torch.cat([self.subir2(centro), c2], dim=1))
        d1 = self.dec1(torch.cat([self.subir1(d2), c1], dim=1))
        return self.salida(d1)                  # (3, 48, 48)


torch.manual_seed(0)
unet = UNet().to(device)
x_val = torch.tensor(X_val).unsqueeze(1).to(device)   # (200, 1, 48, 48)
y_val = torch.tensor(Y_val).to(device)
print("Salida:", tuple(unet(x_val[:2]).shape))
print("Parámetros:", sum(p.numel() for p in unet.parameters()))
def predecir_mascaras(modelo, x):
    modelo.eval()
    with torch.no_grad():
        return modelo(x).argmax(dim=1)          # la clase con más puntaje


def iou_clases(pred, real, clases=3):
    """IoU de las máscaras de cada clase."""
    return [((pred == c) & (real == c)).sum().item()
            / ((pred == c) | (real == c)).sum().item()
            for c in range(clases)]


cargador = DataLoader(
    TensorDataset(torch.tensor(X_ent).unsqueeze(1), torch.tensor(Y_ent)),
    batch_size=32, shuffle=True, generator=torch.Generator().manual_seed(0))
optimizador = torch.optim.Adam(unet.parameters(), lr=3e-3)
perdida_pixel = nn.CrossEntropyLoss()

historia = {0: predecir_mascaras(unet, x_val[:1])[0].cpu()}
for epoca in range(1, 9):
    unet.train()
    suma = 0.0
    for x, y in cargador:
        x, y = x.to(device), y.to(device)
        optimizador.zero_grad()
        perdida = perdida_pixel(unet(x), y)
        perdida.backward()
        optimizador.step()
        suma += perdida.item() * len(x)
    pred = predecir_mascaras(unet, x_val)
    historia[epoca] = pred[0].cpu()
    fondo, circulo, cuadrado = iou_clases(pred, y_val)
    print(f"Época {epoca}: pérdida {suma / len(X_ent):.3f}, IoU círculo "
          f"{circulo:.2f}, cuadrado {cuadrado:.2f}, fondo {fondo:.2f}")
fig, ejes = plt.subplots(2, 3, figsize=(4.8, 3.6))
ejes[0, 0].imshow(X_val[0], cmap=blanco_tinta, vmin=0, vmax=1)
ejes[0, 0].set_title("Imagen", fontsize=12)
ejes[0, 1].imshow(Y_val[0], cmap=colores_mascara, vmin=0, vmax=2)
ejes[0, 1].set_title("Real", fontsize=12)
for eje, epoca in zip(ejes.flat[2:], [0, 1, 3, 8]):
    eje.imshow(historia[epoca], cmap=colores_mascara, vmin=0, vmax=2)
    eje.set_title(f"Época {epoca}", fontsize=12)
for eje in ejes.flat:
    eje.axis("off")
plt.show()
def dice_clase(pred, real, c):
    """Coeficiente de Dice de la clase c."""
    interseccion = ((pred == c) & (real == c)).sum().item()
    return 2 * interseccion / ((pred == c).sum().item()
                               + (real == c).sum().item())


pred = predecir_mascaras(unet, x_val)
print(f"Exactitud por píxel de la U-Net: "
      f"{(pred == y_val).float().mean():.3f}")
print(f"Exactitud de decir siempre «fondo»: {(y_val == 0).float().mean():.3f}")
for c, nombre in [(1, "círculo"), (2, "cuadrado")]:
    print(f"{nombre}: IoU {iou_clases(pred, y_val)[c]:.3f}, "
          f"Dice {dice_clase(pred, y_val, c):.3f}")

Aumentar datos sin romper las etiquetas

from torchvision import tv_tensors
from torchvision.transforms import v2

imagen = tv_tensors.Image(torch.tensor(X_val[:1]))
mascara = tv_tensors.Mask(torch.tensor(Y_val[0]))
caja = tv_tensors.BoundingBoxes([[10, 5, 20, 15]], format="XYXY",
                                canvas_size=(LADO, LADO))

voltear = v2.RandomHorizontalFlip(p=1)    # p=1: voltea siempre
imagen_v, mascara_v, caja_v = voltear(imagen, mascara, caja)
print("Caja a mano:", [LADO - 20, 5, LADO - 10, 15])
print("Caja con v2:", caja_v.tolist()[0])
print("¿La máscara se volteó igual que la imagen?",
      torch.equal(mascara_v, mascara.flip(-1)))