Detectar y segmentar: dónde está cada cosa

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

Los 23 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; 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)))