Detectar y segmentar: dónde está cada cosa
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)))¿Terminaste este tema?
Márcalo y en la ruta verás cuánto llevas. Se guarda solo en este navegador.