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 fila precision, recall, AP, mAP@0.5:0.95 en 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

  1. (Fácil) Implementa box_iou y córrelo contra torchvision.ops.box_iou sobre 1.000 pares de cajas aleatorios. Verifica que la diferencia absoluta máxima queda por debajo de 1e-6.
  2. (Medio) Porta yolo_loss a una versión que usa pérdida de caja CIoU en 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.
  3. (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

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).