Phase 04 - Lesson 06
Detección de Objetos — YOLO desde Cero
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
La detección es clasificación más regresión, ejecutada en cada posición de un mapa de características y luego depurada con supresión no-máxima.
Tipo: Build Lenguajes: Python Prerrequisitos: Fase 4 Lección 03 (CNNs), Fase 4 Lección 04 (Clasificación de Imágenes), Fase 4 Lección 05 (Transfer Learning) Tiempo: ~75 minutos
Objetivos de Aprendizaje
- Explicar el diseño de grilla-y-ancla que convierte la detección en un problema de predicción densa y decir qué significa cada número en el tensor de salida
- Calcular la Intersección-sobre-Unión entre cajas e implementar la supresión no-máxima desde cero
- Construir una cabeza mínima al estilo YOLO sobre un backbone preentrenado, incluyendo las pérdidas de clasificación, de objectness y de regresión de caja
- Leer una fila de métricas de detección (precision@0.5, recall, mAP@0.5, mAP@0.5:0.95) y elegir qué perilla girar a continuación
El Problema
La clasificación dice "esta imagen es un perro". La detección dice "hay un perro en los píxeles (112, 40, 280, 210), hay un gato en (400, 180, 560, 310) y nada más en el cuadro". Ese único cambio estructural — predecir un número variable de cajas etiquetadas en lugar de una etiqueta por imagen — es de lo que dependen todo sistema autónomo, todo producto de vigilancia, todo parser de diseño de documentos y toda línea de visión de fábrica.
La detección también es donde aparecen de una sola vez todos los trade-offs de ingeniería en visión. Quieres cajas precisas (cabeza de regresión), quieres la clase correcta para cada caja (cabeza de clasificación), quieres que el modelo sepa cuándo no hay nada que detectar (puntaje de objectness) y quieres exactamente una predicción por objeto real (supresión no-máxima). Falla en cualquiera de estos y el pipeline o pierde objetos, o reporta cajas alucinadas, o predice el mismo objeto quince veces en posiciones ligeramente distintas.
YOLO (You Only Look Once, Redmon et al. 2016) fue el diseño que hizo que todo esto corriera en tiempo real al lograrlo con una sola pasada hacia adelante de una red convolucional, y las mismas decisiones estructurales siguen siendo la columna vertebral de los detectores modernos (YOLOv8, YOLOv9, YOLO-NAS, RT-DETR). Aprende el núcleo y toda variante se vuelve una reorganización de las mismas partes.
El Concepto
Detección como predicción densa
Un clasificador produce C números por imagen. Un detector al estilo YOLO produce (S x S x (5 + C)) números por imagen, donde S es el tamaño de la grilla espacial.
flowchart LR
IMG["Entrada 416x416 RGB"] --> BB["Backbone<br/>(ResNet, DarkNet, ...)"]
BB --> FM["Mapa de características<br/>(C_feat, 13, 13)"]
FM --> HEAD["Cabeza de detección<br/>(convs 1x1)"]
HEAD --> OUT["Tensor de salida<br/>(13, 13, B * (5 + C))"]
OUT --> DEC["Decode<br/>(grilla + sigmoid + exp)"]
DEC --> NMS["Supresión no-máxima"]
NMS --> RESULT["Cajas finales"]
style IMG fill:#dbeafe,stroke:#2563eb
style HEAD fill:#fef3c7,stroke:#d97706
style NMS fill:#fecaca,stroke:#dc2626
style RESULT fill:#dcfce7,stroke:#16a34a
Cada una de las S * S celdas de la grilla predice B cajas. Para cada caja:
- 4 números describen la geometría:
tx, ty, tw, th. - 1 número es el puntaje de objectness: "¿hay un objeto centrado en esta celda?"
- C números son probabilidades de clase.
Total por celda: B * (5 + C). Para VOC con S=13, B=2, C=20, son 50 números por celda.
Por qué grillas y anclas
La regresión simple predeciría (x, y, w, h) para cada objeto como una coordenada absoluta. Eso es difícil para una red convolucional porque trasladar la imagen no debería trasladar todas las predicciones por la misma cantidad — cada objeto está anclado espacialmente. La grilla resuelve esto asignando cada caja de verdad-fundamental a la celda de la grilla donde cae su centro; solo esa celda es responsable de ese objeto.
Las anclas abordan un segundo problema. Una conv 3x3 no puede regredir fácilmente una caja de 500 píxeles de ancho a partir de una celda de características con campo receptivo de 16 píxeles. En cambio, predefinimos B formas de caja a priori (anclas) por celda y predecimos pequeños deltas a partir de cada ancla. El modelo aprende a elegir el ancla correcta y ajustarla en vez de regredir desde cero.
Anchor box priors (example for 416x416 input):
small: (30, 60)
medium: (75, 170)
large: (200, 380)
At each grid cell, every anchor emits (tx, ty, tw, th, obj, c_1, ..., c_C).
Los detectores modernos a menudo usan FPN con conjuntos de anclas distintos por resolución — anclas pequeñas en mapas superficiales de alta resolución, anclas grandes en mapas profundos de baja resolución. Misma idea, más escalas.
Decodificando predicciones
Los valores crudos tx, ty, tw, th no son coordenadas de caja; son objetivos de regresión que deben transformarse antes de graficar:
centre x = (sigmoid(tx) + cell_x) * stride
centre y = (sigmoid(ty) + cell_y) * stride
width = anchor_w * exp(tw)
height = anchor_h * exp(th)
sigmoid mantiene los offsets del centro dentro de la celda. exp permite que el ancho escale libremente desde el ancla sin cambio de signo. stride reescala las coordenadas de la grilla de vuelta a píxeles. Este paso de decode es el mismo en toda versión de YOLO desde la v2.
IoU
La métrica de similitud universal de la detección entre dos cajas:
IoU(A, B) = area(A intersect B) / area(A union B)
IoU = 1 significa idéntico; IoU = 0 significa ninguna superposición. La IoU entre la predicción y la caja de verdad-fundamental es lo que decide si una predicción cuenta como verdadero positivo (típicamente IoU >= 0.5). La IoU entre dos predicciones es lo que la NMS usa para deduplicar.
Supresión no-máxima
Una red convolucional entrenada en anclas adyacentes a menudo predecirá cajas superpuestas para el mismo objeto. La NMS conserva la predicción de mayor confianza y borra cualquier otra predicción con IoU por encima de un umbral.
NMS(boxes, scores, iou_threshold):
sort boxes by score descending
keep = []
while boxes not empty:
pick the top-scoring box, add to keep
remove every box with IoU > iou_threshold to the picked box
return keep
Umbral típico: 0.45 para detección de objetos. Los detectores recientes reemplazan la NMS estándar por soft-NMS, DIoU-NMS, o aprenden la supresión directamente (RT-DETR), pero el propósito estructural es el mismo.
La pérdida
La pérdida de YOLO son tres pérdidas sumadas con pesos:
L = lambda_coord * L_box(pred, target, where obj=1)
+ lambda_obj * L_obj(pred, 1, where obj=1)
+ lambda_noobj * L_obj(pred, 0, where obj=0)
+ lambda_cls * L_cls(pred, target, where obj=1)
Solo las celdas que contienen un objeto contribuyen a las pérdidas de regresión de caja y de clasificación. Las celdas sin objetos contribuyen solo a la pérdida de objectness (enseñando al modelo a permanecer en silencio). lambda_noobj suele ser pequeño (~0.5) porque la gran mayoría de las celdas están vacías y de otra forma dominarían la pérdida total.
Las variantes modernas cambian la pérdida de caja MSE por CIoU / DIoU (que optimizan la IoU directamente), usan focal loss para el desbalance de clases y equilibran el objectness con quality focal loss. La estructura de tres componentes permanece sin cambios.
Métricas de detección
La exactitud no se transfiere a la detección. Cuatro números que sí lo hacen:
- Precision@IoU=0.5 — de las predicciones contadas como positivas, cuántas son realmente correctas.
- Recall@IoU=0.5 — de los objetos reales, cuántos encontramos.
- AP@0.5 — área de la curva precision-recall al umbral de IoU 0.5; un número por clase.
- mAP@0.5:0.95 — promedio de la AP sobre los umbrales de IoU 0.5, 0.55, ..., 0.95. La métrica de COCO; la más estricta y la más informativa.
Reporta los cuatro. Un detector fuerte en mAP@0.5 pero débil en mAP@0.5:0.95 está localizando de forma aproximada pero no ajustada; corrígelo con una mejor pérdida de regresión de caja. Un detector con alta precision y bajo recall es demasiado conservador; baja el umbral de confianza o aumenta el peso de objectness.
Constrúyelo
Paso 1: IoU
El caballo de batalla de toda la lección. Funciona sobre dos arrays de cajas en formato (x1, y1, x2, y2).
import numpy as np
def box_iou(boxes_a, boxes_b):
ax1, ay1, ax2, ay2 = boxes_a[:, 0], boxes_a[:, 1], boxes_a[:, 2], boxes_a[:, 3]
bx1, by1, bx2, by2 = boxes_b[:, 0], boxes_b[:, 1], boxes_b[:, 2], boxes_b[:, 3]
inter_x1 = np.maximum(ax1[:, None], bx1[None, :])
inter_y1 = np.maximum(ay1[:, None], by1[None, :])
inter_x2 = np.minimum(ax2[:, None], bx2[None, :])
inter_y2 = np.minimum(ay2[:, None], by2[None, :])
inter_w = np.clip(inter_x2 - inter_x1, 0, None)
inter_h = np.clip(inter_y2 - inter_y1, 0, None)
inter = inter_w * inter_h
area_a = (ax2 - ax1) * (ay2 - ay1)
area_b = (bx2 - bx1) * (by2 - by1)
union = area_a[:, None] + area_b[None, :] - inter
return inter / np.clip(union, 1e-8, None)
Devuelve una matriz (N_a, N_b) de IoUs por pares. Úsala contra una sola caja de verdad-fundamental haciendo que uno de los arrays tenga shape (1, 4).
Paso 2: Supresión no-máxima
def nms(boxes, scores, iou_threshold=0.45):
order = np.argsort(-scores)
keep = []
while len(order) > 0:
i = order[0]
keep.append(i)
if len(order) == 1:
break
rest = order[1:]
ious = box_iou(boxes[[i]], boxes[rest])[0]
order = rest[ious <= iou_threshold]
return np.array(keep, dtype=np.int64)
Determinista, O(N log N) por el ordenamiento, y coincide con el comportamiento de torchvision.ops.nms sobre entradas idénticas.
Paso 3: Codificación y decodificación de cajas
Convierte entre coordenadas de píxel y los objetivos (tx, ty, tw, th) que la red realmente regrede.
def encode(box_xyxy, cell_x, cell_y, stride, anchor_wh):
x1, y1, x2, y2 = box_xyxy
cx = 0.5 * (x1 + x2)
cy = 0.5 * (y1 + y2)
w = x2 - x1
h = y2 - y1
tx = cx / stride - cell_x
ty = cy / stride - cell_y
tw = np.log(w / anchor_wh[0] + 1e-8)
th = np.log(h / anchor_wh[1] + 1e-8)
return np.array([tx, ty, tw, th])
def decode(tx_ty_tw_th, cell_x, cell_y, stride, anchor_wh):
tx, ty, tw, th = tx_ty_tw_th
cx = (sigmoid(tx) + cell_x) * stride
cy = (sigmoid(ty) + cell_y) * stride
w = anchor_wh[0] * np.exp(tw)
h = anchor_wh[1] * np.exp(th)
return np.array([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2])
def sigmoid(x):
return 1.0 / (1.0 + np.exp(-x))
Prueba: codifica una caja y luego decodifícala — deberías recuperar algo muy cercano al original (hasta donde la inversa del sigmoid no es perfectamente invertible cuando tx no está en el rango post-sigmoid).
Paso 4: Una cabeza YOLO mínima
Una conv 1x1 sobre un mapa de características, reformando a (B, S, S, num_anchors, 5 + C).
import torch
import torch.nn as nn
class YOLOHead(nn.Module):
def __init__(self, in_c, num_anchors, num_classes):
super().__init__()
self.num_anchors = num_anchors
self.num_classes = num_classes
self.conv = nn.Conv2d(in_c, num_anchors * (5 + num_classes), kernel_size=1)
def forward(self, x):
n, _, h, w = x.shape
y = self.conv(x)
y = y.view(n, self.num_anchors, 5 + self.num_classes, h, w)
y = y.permute(0, 3, 4, 1, 2).contiguous()
return y
Shape de salida: (N, H, W, num_anchors, 5 + C). La última dimensión contiene [tx, ty, tw, th, obj, cls_0, ..., cls_{C-1}].
Paso 5: Asignación de verdad-fundamental
Para cada caja de verdad-fundamental, decide qué (cell, anchor) es responsable.
def assign_targets(boxes_xyxy, classes, anchors, stride, grid_size, num_classes):
num_anchors = len(anchors)
target = np.zeros((grid_size, grid_size, num_anchors, 5 + num_classes), dtype=np.float32)
has_obj = np.zeros((grid_size, grid_size, num_anchors), dtype=bool)
for box, cls in zip(boxes_xyxy, classes):
x1, y1, x2, y2 = box
cx, cy = 0.5 * (x1 + x2), 0.5 * (y1 + y2)
gx, gy = int(cx / stride), int(cy / stride)
bw, bh = x2 - x1, y2 - y1
ious = np.array([
(min(bw, aw) * min(bh, ah)) / (bw * bh + aw * ah - min(bw, aw) * min(bh, ah))
for aw, ah in anchors
])
best = int(np.argmax(ious))
aw, ah = anchors[best]
target[gy, gx, best, 0] = cx / stride - gx
target[gy, gx, best, 1] = cy / stride - gy
target[gy, gx, best, 2] = np.log(bw / aw + 1e-8)
target[gy, gx, best, 3] = np.log(bh / ah + 1e-8)
target[gy, gx, best, 4] = 1.0
target[gy, gx, best, 5 + cls] = 1.0
has_obj[gy, gx, best] = True
return target, has_obj
La selección de ancla es "mejor IoU de forma con la verdad-fundamental" — un proxy barato que coincide con la asignación de YOLOv2/v3. La v5 y posteriores usan estrategias más sofisticadas (task-aligned matching, dynamic k) que refinan la misma idea.
Paso 6: Las tres pérdidas
def yolo_loss(pred, target, has_obj, lambda_coord=5.0, lambda_obj=1.0, lambda_noobj=0.5, lambda_cls=1.0):
has_obj_t = torch.from_numpy(has_obj).bool()
target_t = torch.from_numpy(target).float()
# box-regression loss: only on cells with objects
box_pred = pred[..., :4][has_obj_t]
box_true = target_t[..., :4][has_obj_t]
loss_box = torch.nn.functional.mse_loss(box_pred, box_true, reduction="sum")
# objectness loss
obj_pred = pred[..., 4]
obj_true = target_t[..., 4]
loss_obj_pos = torch.nn.functional.binary_cross_entropy_with_logits(
obj_pred[has_obj_t], obj_true[has_obj_t], reduction="sum")
loss_obj_neg = torch.nn.functional.binary_cross_entropy_with_logits(
obj_pred[~has_obj_t], obj_true[~has_obj_t], reduction="sum")
# classification loss on cells with objects
cls_pred = pred[..., 5:][has_obj_t]
cls_true = target_t[..., 5:][has_obj_t]
loss_cls = torch.nn.functional.binary_cross_entropy_with_logits(
cls_pred, cls_true, reduction="sum")
total = (lambda_coord * loss_box
+ lambda_obj * loss_obj_pos
+ lambda_noobj * loss_obj_neg
+ lambda_cls * loss_cls)
return total, {"box": loss_box.item(), "obj_pos": loss_obj_pos.item(),
"obj_neg": loss_obj_neg.item(), "cls": loss_cls.item()}
Cinco hiperparámetros que todo tutorial de YOLO o fija en el código o barre. Las proporciones importan: lambda_coord=5, lambda_noobj=0.5 refleja el artículo original de YOLOv1 y todavía funciona como un default razonable.
Paso 7: Pipeline de inferencia
Decodifica la salida cruda de la cabeza, aplica sigmoid/exp, aplica umbral al objectness y NMS.
def postprocess(pred_tensor, anchors, stride, img_size, conf_threshold=0.25, iou_threshold=0.45):
pred = pred_tensor.detach().cpu().numpy()
grid_h, grid_w = pred.shape[1], pred.shape[2]
num_anchors = len(anchors)
boxes, scores, classes = [], [], []
for gy in range(grid_h):
for gx in range(grid_w):
for a in range(num_anchors):
tx, ty, tw, th, obj, *cls = pred[0, gy, gx, a]
score = sigmoid(obj) * sigmoid(np.array(cls)).max()
if score < conf_threshold:
continue
cls_idx = int(np.argmax(cls))
cx = (sigmoid(tx) + gx) * stride
cy = (sigmoid(ty) + gy) * stride
w = anchors[a][0] * np.exp(tw)
h = anchors[a][1] * np.exp(th)
boxes.append([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2])
scores.append(float(score))
classes.append(cls_idx)
if not boxes:
return np.zeros((0, 4)), np.zeros((0,)), np.zeros((0,), dtype=int)
boxes = np.array(boxes)
scores = np.array(scores)
classes = np.array(classes)
keep = nms(boxes, scores, iou_threshold)
return boxes[keep], scores[keep], classes[keep]
Ese es el camino completo de evaluación: cabeza -> decode -> umbral -> NMS.
Úsalo
torchvision.models.detection entrega detectores de producción con la misma estructura conceptual. Cargar un modelo preentrenado toma tres líneas.
import torch
from torchvision.models.detection import fasterrcnn_resnet50_fpn_v2
model = fasterrcnn_resnet50_fpn_v2(weights="DEFAULT")
model.eval()
with torch.no_grad():
predictions = model([torch.randn(3, 400, 600)])
print(predictions[0].keys())
print(f"boxes: {predictions[0]['boxes'].shape}")
print(f"scores: {predictions[0]['scores'].shape}")
print(f"labels: {predictions[0]['labels'].shape}")
Para pipelines de inferencia en tiempo real, ultralytics (YOLOv8/v9) es el estándar: from ultralytics import YOLO; model = YOLO('yolov8n.pt'); model(img). El modelo maneja la decodificación y la NMS internamente y devuelve la misma tripleta boxes / scores / labels que construiste arriba.
Entrégalo
Esta lección produce:
outputs/prompt-detection-metric-reader.md— un prompt que convierte una filaprecision, recall, AP, mAP@0.5:0.95en un diagnóstico de una línea y en el único experimento siguiente más útil.outputs/skill-anchor-designer.md— una skill que, dado un dataset de cajas de verdad-fundamental, corre k-means sobre(w, h)y devuelve conjuntos de anclas por nivel de FPN más las estadísticas de cobertura que necesitas para elegir el número correcto de anclas.
Ejercicios
- (Fácil) Implementa
box_iouy córrelo contratorchvision.ops.box_iousobre 1.000 pares de cajas aleatorios. Verifica que la diferencia absoluta máxima queda por debajo de1e-6. - (Medio) Porta
yolo_lossa una versión que usa pérdida de cajaCIoUen lugar de MSE. Muestra, en un dataset sintético de 100 imágenes, que CIoU converge a un mAP@0.5:0.95 final mejor que MSE en el mismo número de épocas. - (Difícil) Implementa inferencia multiescala: alimenta la misma imagen en tres resoluciones a través del modelo, une las predicciones de caja y corre una sola NMS al final. Mide la ganancia de mAP frente a la inferencia de escala única sobre un conjunto reservado.
Términos Clave
| Término | Lo que la gente dice | Lo que realmente significa |
|---|---|---|
| Anchor | "Box prior" | Una forma de caja predefinida en cada celda de la grilla a partir de la cual la red predice deltas en vez de coordenadas absolutas |
| IoU | "Superposición" | Intersección-sobre-unión de dos cajas; la medida de similitud universal en detección |
| NMS | "Deduplicar" | Algoritmo voraz que conserva las predicciones de mayor score y elimina las superpuestas por encima de un umbral |
| Objectness | "¿Hay algo aquí?" | Escalar por ancla y por celda que predice si un objeto está centrado en esa celda |
| Grid stride | "Factor de downsample" | Píxeles por celda de la grilla; una entrada de 416-px con una cabeza de grilla 13 tiene stride 32 |
| mAP | "Mean average precision" | Promedio del área bajo la curva precision-recall, promediado sobre las clases y (para COCO) sobre los umbrales de IoU |
| AP@0.5 | "PASCAL VOC AP" | Average precision con umbral de IoU 0.5; la versión indulgente de la métrica |
| mAP@0.5:0.95 | "COCO AP" | Promedio sobre los umbrales de IoU 0.5..0.95 con paso 0.05; la versión estricta y el estándar actual de la comunidad |
Lecturas Adicionales
- YOLOv1: You Only Look Once (Redmon et al., 2016) — el artículo fundador; todo YOLO desde entonces es un refinamiento de esta estructura
- YOLOv3 (Redmon & Farhadi, 2018) — el artículo que introdujo las cabezas multiescala al estilo FPN; todavía el diagrama más claro
- Documentación de Ultralytics YOLOv8 — la referencia de producción actual; cubre formatos de dataset, augmentations y recetas de entrenamiento
- The Illustrated Guide to Object Detection (Jonathan Hui) — el mejor recorrido en lenguaje sencillo por todo el zoológico de detectores; invaluable para entender cómo se relacionan DETR, RetinaNet, FCOS y YOLO