SO Development

Implementando YOLO desde cero en PyTorch

Índice del Contenido
    Agregue un encabezado para comenzar a generar la tabla de contenido

    Introducción – Por qué YOLO lo cambió todo

    Antes de YOLO, las computadoras no “veían” el mundo como lo hacen los humanos.
    Los sistemas de detección de objetos eran cuidadosos, lentos y fragmentados. Primero propusieron regiones que puede Contenía objetos y luego clasificaba cada región por separado. La detección funcionó, pero parecía como resolver un rompecabezas pieza por pieza.

    En 2015, YOLO—Solo miras una vez—introdujo una idea radical:

    ¿Qué pasaría si detectáramos todo en un único pase hacia adelante?

    En lugar de múltiples etapas, YOLO trató la detección como una problema de regresión simple Desde píxeles hasta cuadros delimitadores y probabilidades de clase.

    Esta guía explica cómo Implementar YOLO completamente desde cero en PyTorch, cubriendo:

    • Formulación matemática

    • Red de arquitectura

    • Codificación de destino

    • Implementación de pérdidas

    • Entrenamiento en datos estilo COCO

    • Evaluación mAP

    • Visualización y depuración

    • Inferencia con NMS

    • Extensión de caja de anclaje

    1) Qué significa YOLO (y qué construiremos)

    YOLO (Solo se mira una vez) Es una familia de modelos de detección de objetos que predicen cuadros delimitadores y probabilidades de clase. en un pase hacia adelanteA diferencia de las antiguas canalizaciones multietapa (propuesta → refinar → clasificar), los detectores de estilo YOLO son predictores densos:predicen cuadros candidatos en muchas ubicaciones y escalas, y luego los filtran.

    Hay dos “eras” de detectores tipo YOLO:

    • Estilo YOLOv1 (celdas de cuadrícula, sin anclajes):cada celda de la cuadrícula predice algunos cuadros directamente.

    • YOLO basado en ancla (YOLOv2/3 y muchos derivados):cada celda de la cuadrícula predice desplazamientos relativos a formas de anclaje predefinidas; múltiples escalas predicen objetos pequeños/medianos/grandes.

    Lo que implementaremos

    A Detector moderno de estilo YOLO basado en ancla con:

    • Cabezales multiescala (p. ej., 3 escalas)

    • Coincidencia de anclajes (asignación de objetivos)

    • Pérdida con regresión de caja + objetividad + clasificación

    • Decodificación + NMS

    • Evaluación mAP

    • Soporte para capacitación en conjuntos de datos personalizados/COCO

    Mantendremos la arquitectura comprensible, no exótica. Posteriormente, podrás implementar fácilmente una red troncal más grande.

    2) Formatos de cuadros delimitadores y sistemas de coordenadas

    Debes ser constante. La mayoría de los errores de entrenamiento se deben a confusiones con el formato de las cajas.

    Formatos de caja comunes:

    • XYXY: (x1, y1, x2, y2) arriba a la izquierda y abajo a la derecha

    • XYWH: (cx, cy, w, h) centro y tamaño

    • Normalizado: coordenadas en [0, 1] relativo al tamaño de la imagen

    • Absoluto: coordenadas de píxeles

    Convención interna recomendada

    • Almacenar anotaciones del conjunto de datos como XYXY absoluto en píxeles.

    • Convierta a normalizado solo si es necesario, pero mantenga un estándar.

    Por qué XYXY es agradable:

    • La intersección/unión es sencilla.

    • La sujeción a los límites de la imagen es sencilla.

    3) IoU, GIoU, DIoU, CIoU

    IoU (Intersección sobre Unión) es la métrica de superposición estándar:

    IoU=∣A∩B∣/∣A∪B∣

    Pero IoU presenta un problema: si las cajas no se superponen, IoU = 0, el gradiente puede ser débil. Los detectores modernos suelen utilizar pérdidas de regresión mejoradas:

    • GIoU: agrega penalización para cuadros que no se superponen según el cuadro adjunto más pequeño

    • DIoU: penaliza la distancia al centro

    • CIoU: DIoU + consistencia de la relación de aspecto

    Regla práctica:

    • Si desea un valor predeterminado fuerte: CIoU para regresión de caja.

    • Si quieres algo más simple: GIoU Funciona bien también.

    Implementaremos IoU + CIoU (con números seguros).

    4) YOLO basado en anclas: cuadrículas, anclas, predicciones

    Una cabeza YOLO predice en cada ubicación de la cuadrícula. Supongamos que un mapa de características es S x S (p. ej., 80×80). Cada celda puede predecir A Anclas (p. ej., 3). Para cada ancla, la predicción es:

    • Desplazamientos de caja: tx, ty, tw, th

    • Logit de objetividad: to

    • logits de clase: tc1..tcC

    Entonces la forma del tensor por escala es:
    (B, A*(5+C), S, S) or (B, A, S, S, 5+C) Después de remodelar.

    Cómo las compensaciones se convierten en cajas reales

    Una decodificación común de estilo YOLO (una de varias variantes válidas):

    • bx = (sigmoid(tx) + cx) / S

    • by = (sigmoid(ty) + cy) / S

    • bw = (anchor_w * exp(tw)) / img_w (o normalizado por S)

    • bh = (anchor_h * exp(th)) / img_h

    ¿Donde (cx, cy) es la coordenada de la cuadrícula entera.

    Importante:Su codificación/descodificación debe coincidir con la codificación de su asignación de destino.

    5) Preparación del conjunto de datos

    Formatos de anotación

    Su conjunto de datos personalizado puede ser:

    • COCO JSON

    • XML de VOC de Pascal

    • YOLO txt (clase cx cy wh normalizado)

    Apoyaremos una representación interna genérica:

    • Cada muestra devuelve:

      • image: Tensor [3, H, W]

      • targets: Tensor [N, 6] con columnas:

        • [class, x1, y1, x2, y2, image_index(optional)]

    Aumentos

    Para la detección de objetos, los aumentos también deben transformar los cuadros:

    • Cambiar tamaño / buzón

    • Volteo horizontal aleatorio

    • Vibración de color

    • Afín aleatorio (opcional)

    • Mosaico/mezcla (avanzado; opcional)

    Para mantener esta guía implementable sin geometría frágil, haremos lo siguiente:

    • cambiar tamaño/buzón

    • voltereta aleatoria

    • Fluctuación del HSV (opcional)

    6) Bloques de construcción: Conv-BN-Act, residuos, cuellos

    Un módulo de línea base limpio:

    • Conv2d -> BatchNorm2d -> SiLU
      SiLU (también conocido como Swish) es común en familias similares a YOLOv5; LeakyReLU es común en YOLOv3.

    Opcionalmente, podemos agregar bloques residuales para una red troncal más fuerte, pero incluso una red troncal pequeña puede funcionar para validar la canalización.

    7) Diseño del modelo

    Una estructura típica:

    • Columna vertebral:extrae mapas de características en múltiples pasos (8, 16, 32)

    • Cuello: combina características (FPN / PAN)

    • Cabeza: predice resultados de detección por escala

    Implementaremos una red troncal liviana que produzca 3 mapas de características y un cuello simple similar a FPN.

    8) Decodificando predicciones

    En la inferencia:

    1. Remodelar las salidas por escala a (B, A, S, S, 5+C)

    2. Aplicar sigmoide a los desplazamientos centrales + objetividad (y a menudo problemas de clase)

    3. Convertir a XYXY en coordenadas de píxeles

    4. Aplanar todas las escalas en una lista de cuadros candidatos

    5. Filtrar por umbral de confianza

    6. Aplicar NMS por clase (o NMS independiente de la clase)

    9) Asignación de objetivos (comparación de GT con los anclajes)

    Éste es el corazón de YOLO basado en anclas.

    Para cada casilla de verdad fundamental:

    1. Determinar qué escala(s) debería(n) manejarlo (según el tamaño/coincidencia de anclaje).

    2. Para la escala elegida, calcule el IoU entre el tamaño del cuadro GT y el tamaño de cada anclaje (en el sistema de coordenadas de esa escala).

    3. Seleccione el mejor ancla (o los mejores k anclas).

    4. Calcular el índice de celda de la cuadrícula desde el centro GT.

    5. Rellene los tensores de destino en [anchor, gy, gx] con:

      • objetivos de regresión de caja

      • objetividad = 1

      • objetivo de la clase

    Codificación de objetivos de regresión

    Si utiliza decodificación:

    • bx = (sigmoid(tx) + cx)/S
      Entonces apunta a tx is sigmoid^-1(bx*S - cx) pero eso es un desastre.

    En cambio, el entrenamiento estilo YOLO a menudo supervisa directamente:

    • tx_target = bx*S - cx (un valor en [0,1]) y se entrena con BCE en salida sigmoidea, o MSE en bruto.

    • tw_target = log(bw / anchor_w) (en píxeles o unidades normalizadas)

    Implementaremos una variante estable:

    • predecir pxy = sigmoid(tx,ty) y supervisar pxy con BCE/MSE para que coincida con las compensaciones fraccionarias

    • predecir pwh = exp(tw,th)*anchor y supervisar con CIoU las cajas decodificadas (recomendado)

    Esto es más sencillo: ¿Se produce pérdida de regresión en las cajas decodificadas?, no en tw/th directamente.

    10) Funciones de pérdida

    La pérdida estilo YOLO generalmente tiene:

    1. Pérdida de caja:CIoU/GIoU entre el cuadro previsto y el cuadro GT en las ubicaciones responsables

    2. Pérdida de objetividad: BCEWithLogits sobre logit de objetividad

    3. Pérdida de clase: BCEWithLogits (etiqueta múltiple) o CE (etiqueta única)

    Para la clasificación de etiqueta única (una clase por objeto), funciona cualquiera de las dos opciones:

    • BCEWithLogits con objetivos one-hot (común en YOLO)

    • CrossEntropyLoss en logits de clase en ubicaciones positivas (también correcto)

    Usaremos BCEWithLogits tanto para la objetividad como para las clases para mantener la coherencia.

    Manejo de negativos

    Tendrás muchas más posiciones negativas (sin objeto). Puedes:

    • Utilice un peso menor para la objetividad negativa

    • O aplicar pérdida focal (opcional)

    Implementaremos:

    • pérdida de objetividad con pesos positivos y negativos.

    11) Bucle de entrenamiento

    Características clave para la estabilidad/rendimiento:

    • AMP (antorcha.cuda.amp)

    • Recorte de degradado (opcional)

    • EMA pesas (opcional pero útil)

    • Programador LR (coseno o paso)

    • Calentamiento para las primeras épocas/pasos

    Utilice esto en capturas de pantalla de Playwright:

    12) NMS

    La supresión no máxima elimina los duplicados superpuestos. Procedimiento típico:

    • Ordenar las cajas por confianza

    • Iterar la configuración más alta, suprimir cuadros con IoU > umbral

    Utilice NMS clase por clase para la detección de múltiples clases.

    13) Evaluación mAP

    La precisión media promedio requiere:

    • Para cada clase, calcule la curva de precisión-recuperación en los umbrales de IoU

    • Integrar el área bajo la curva (AP)

    • Promedio entre clases (mAP)

    • COCO utiliza mAP en umbrales de IoU de 0.50 a 0.95, paso 0.05

    Implementaremos:

    • mAP@0.5

    • y opcionalmente mAP@[.5:.95] estilo COCO

     

    14) Visualización

    Antes de entrenar seriamente, visualiza:

    • asignaciones de objetivos por escala

    • predicciones decodificadas después de unas cuantas iteraciones

    • Salidas NMS

    Esto detecta el 80% de los problemas del tipo "mi modelo no aprende".

     

    15) Implementación completa del núcleo (código de referencia)

    A continuación se muestra un conjunto compacto pero completo de archivos principales que puede colocar en un repositorio. No es pequeño, pero es legible y está diseñado para ser correcto.

    15.1 Estructura del repo

     
    				
    					yolo_scratch/
      README.md
      train.py
      eval.py
      predict.py
      yolo/
        __init__.py
        model.py
        modules.py
        loss.py
        assigner.py
        box_ops.py
        nms.py
        metrics.py
        data.py
        transforms.py
        utils.py
      configs/
        coco.yaml
        custom.yaml
    
    				
    			
    				
    					yolo_scratch/
      README.md
      train.py
      eval.py
      predict.py
      yolo/
        __init__.py
        model.py
        modules.py
        loss.py
        assigner.py
        box_ops.py
        nms.py
        metrics.py
        data.py
        transforms.py
        utils.py
      configs/
        coco.yaml
        custom.yaml
    
    				
    			

    15.2 yolo/box_ops.py

     
    				
    					import torch
    
    def xyxy_to_xywh(boxes: torch.Tensor) -> torch.Tensor:
        # boxes: [..., 4]
        x1, y1, x2, y2 = boxes.unbind(-1)
        cx = (x1 + x2) * 0.5
        cy = (y1 + y2) * 0.5
        w = (x2 - x1).clamp(min=0)
        h = (y2 - y1).clamp(min=0)
        return torch.stack([cx, cy, w, h], dim=-1)
    
    def xywh_to_xyxy(boxes: torch.Tensor) -> torch.Tensor:
        cx, cy, w, h = boxes.unbind(-1)
        half_w = w * 0.5
        half_h = h * 0.5
        x1 = cx - half_w
        y1 = cy - half_h
        x2 = cx + half_w
        y2 = cy + half_h
        return torch.stack([x1, y1, x2, y2], dim=-1)
    
    def box_iou_xyxy(boxes1: torch.Tensor, boxes2: torch.Tensor, eps: float = 1e-9) -> torch.Tensor:
        # boxes1: [N,4], boxes2: [M,4]
        x11, y11, x12, y12 = boxes1[:, 0], boxes1[:, 1], boxes1[:, 2], boxes1[:, 3]
        x21, y21, x22, y22 = boxes2[:, 0], boxes2[:, 1], boxes2[:, 2], boxes2[:, 3]
    
        inter_x1 = torch.maximum(x11[:, None], x21[None, :])
        inter_y1 = torch.maximum(y11[:, None], y21[None, :])
        inter_x2 = torch.minimum(x12[:, None], x22[None, :])
        inter_y2 = torch.minimum(y12[:, None], y22[None, :])
    
        inter_w = (inter_x2 - inter_x1).clamp(min=0)
        inter_h = (inter_y2 - inter_y1).clamp(min=0)
        inter = inter_w * inter_h
    
        area1 = (x12 - x11).clamp(min=0) * (y12 - y11).clamp(min=0)
        area2 = (x22 - x21).clamp(min=0) * (y22 - y21).clamp(min=0)
        union = area1[:, None] + area2[None, :] - inter
        return inter / (union + eps)
    
    def ciou_loss_xyxy(pred: torch.Tensor, target: torch.Tensor, eps: float = 1e-7) -> torch.Tensor:
        """
        pred, target: [N,4] in xyxy
        Returns: [N] CIoU loss = 1 - CIoU
        """
        # IoU
        iou = box_iou_xyxy(pred, target).diag()  # [N]
    
        # centers and sizes
        p = xyxy_to_xywh(pred)
        t = xyxy_to_xywh(target)
        pcx, pcy, pw, ph = p.unbind(-1)
        tcx, tcy, tw, th = t.unbind(-1)
    
        # center distance
        center_dist2 = (pcx - tcx) ** 2 + (pcy - tcy) ** 2
    
        # smallest enclosing box diagonal squared
        x1 = torch.minimum(pred[:, 0], target[:, 0])
        y1 = torch.minimum(pred[:, 1], target[:, 1])
        x2 = torch.maximum(pred[:, 2], target[:, 2])
        y2 = torch.maximum(pred[:, 3], target[:, 3])
        c2 = (x2 - x1) ** 2 + (y2 - y1) ** 2 + eps
    
        diou = iou - center_dist2 / c2
    
        # aspect ratio penalty
        v = (4 / (torch.pi ** 2)) * (torch.atan(tw / (th + eps)) - torch.atan(pw / (ph + eps))) ** 2
        with torch.no_grad():
            alpha = v / (1 - iou + v + eps)
    
        ciou = diou - alpha * v
        return 1 - ciou.clamp(min=-1.0, max=1.0)
    
    				
    			

    15.3 yolo/nms.py

    				
    					import torch
    from .box_ops import box_iou_xyxy
    
    def nms_xyxy(boxes: torch.Tensor, scores: torch.Tensor, iou_thresh: float = 0.5) -> torch.Tensor:
        """
        boxes: [N,4], scores: [N]
        returns indices kept
        """
        if boxes.numel() == 0:
            return torch.empty((0,), dtype=torch.long, device=boxes.device)
    
        idxs = scores.argsort(descending=True)
        keep = []
    
        while idxs.numel() > 0:
            i = idxs[0]
            keep.append(i)
    
            if idxs.numel() == 1:
                break
    
            rest = idxs[1:]
            ious = box_iou_xyxy(boxes[i].unsqueeze(0), boxes[rest]).squeeze(0)
            idxs = rest[ious <= iou_thresh]
    
        return torch.stack(keep)
    
    def batched_nms_xyxy(boxes, scores, labels, iou_thresh=0.5):
        """
        Class-wise NMS by offsetting boxes or by filtering per class.
        Here: filter per class (clear and correct).
        """
        keep_all = []
        for c in labels.unique():
            mask = labels == c
            keep = nms_xyxy(boxes[mask], scores[mask], iou_thresh)
            keep_all.append(mask.nonzero(as_tuple=False).squeeze(1)[keep])
        if not keep_all:
            return torch.empty((0,), dtype=torch.long, device=boxes.device)
        return torch.cat(keep_all)
    
    				
    			

    15.4 yolo/modules.py

    				
    					import torch
    import torch.nn as nn
    
    class ConvBNAct(nn.Module):
        def __init__(self, in_ch, out_ch, k=3, s=1, p=None, act=True):
            super().__init__()
            if p is None:
                p = k // 2
            self.conv = nn.Conv2d(in_ch, out_ch, k, s, p, bias=False)
            self.bn = nn.BatchNorm2d(out_ch)
            self.act = nn.SiLU(inplace=True) if act else nn.Identity()
    
        def forward(self, x):
            return self.act(self.bn(self.conv(x)))
    
    class Residual(nn.Module):
        def __init__(self, ch):
            super().__init__()
            self.block = nn.Sequential(
                ConvBNAct(ch, ch, 1, 1),
                ConvBNAct(ch, ch, 3, 1),
            )
    
        def forward(self, x):
            return x + self.block(x)
    
    class CSPBlock(nn.Module):
        """
        Light CSP-like block: split channels, apply residuals on one branch, then concat.
        """
        def __init__(self, ch, n=1):
            super().__init__()
            c_ = ch // 2
            self.conv1 = ConvBNAct(ch, c_, 1, 1)
            self.conv2 = ConvBNAct(ch, c_, 1, 1)
            self.m = nn.Sequential(*[Residual(c_) for _ in range(n)])
            self.conv3 = ConvBNAct(2 * c_, ch, 1, 1)
    
        def forward(self, x):
            y1 = self.m(self.conv1(x))
            y2 = self.conv2(x)
            return self.conv3(torch.cat([y1, y2], dim=1))
    
    				
    			

    15.5 yolo/model.py

    				
    					import torch
    import torch.nn as nn
    from .modules import ConvBNAct, CSPBlock
    
    class TinyBackbone(nn.Module):
        """
        Produces 3 feature maps at strides 8, 16, 32.
        """
        def __init__(self, in_ch=3, base=32):
            super().__init__()
            self.stem = nn.Sequential(
                ConvBNAct(in_ch, base, 3, 2),       # stride 2
                ConvBNAct(base, base*2, 3, 2),     # stride 4
                CSPBlock(base*2, n=1),
            )
            self.stage3 = nn.Sequential(
                ConvBNAct(base*2, base*4, 3, 2),   # stride 8
                CSPBlock(base*4, n=2),
            )
            self.stage4 = nn.Sequential(
                ConvBNAct(base*4, base*8, 3, 2),   # stride 16
                CSPBlock(base*8, n=2),
            )
            self.stage5 = nn.Sequential(
                ConvBNAct(base*8, base*16, 3, 2),  # stride 32
                CSPBlock(base*16, n=1),
            )
    
        def forward(self, x):
            x = self.stem(x)
            p3 = self.stage3(x)
            p4 = self.stage4(p3)
            p5 = self.stage5(p4)
            return p3, p4, p5
    
    class SimpleFPN(nn.Module):
        def __init__(self, ch3, ch4, ch5, out_ch=128):
            super().__init__()
            self.lat5 = ConvBNAct(ch5, out_ch, 1, 1)
            self.lat4 = ConvBNAct(ch4, out_ch, 1, 1)
            self.lat3 = ConvBNAct(ch3, out_ch, 1, 1)
    
            self.out4 = ConvBNAct(out_ch, out_ch, 3, 1)
            self.out3 = ConvBNAct(out_ch, out_ch, 3, 1)
    
        def forward(self, p3, p4, p5):
            p5 = self.lat5(p5)
            p4 = self.lat4(p4) + torch.nn.functional.interpolate(p5, scale_factor=2, mode="nearest")
            p3 = self.lat3(p3) + torch.nn.functional.interpolate(p4, scale_factor=2, mode="nearest")
            p4 = self.out4(p4)
            p3 = self.out3(p3)
            return p3, p4, p5
    
    class DetectHead(nn.Module):
        def __init__(self, in_ch, num_anchors, num_classes):
            super().__init__()
            self.num_anchors = num_anchors
            self.num_classes = num_classes
            self.pred = nn.Conv2d(in_ch, num_anchors * (5 + num_classes), 1, 1, 0)
    
        def forward(self, x):
            return self.pred(x)
    
    class YOLO(nn.Module):
        def __init__(self, num_classes, anchors, base=32):
            """
            anchors: list of 3 scales, each is list of (w,h) in pixels for the model input size (e.g., 640)
                     e.g. [
                       [(10,13),(16,30),(33,23)],   # stride 8
                       [(30,61),(62,45),(59,119)],  # stride 16
                       [(116,90),(156,198),(373,326)] # stride 32
                     ]
            """
            super().__init__()
            self.num_classes = num_classes
            self.anchors = anchors
    
            self.backbone = TinyBackbone(in_ch=3, base=base)
            # backbone channels: p3=base*4, p4=base*8, p5=base*16
            self.fpn = SimpleFPN(base*4, base*8, base*16, out_ch=base*4)
    
            na = len(anchors[0])
            self.head3 = DetectHead(base*4, na, num_classes)
            self.head4 = DetectHead(base*4, na, num_classes)
            self.head5 = DetectHead(base*4, na, num_classes)
    
        def forward(self, x):
            p3, p4, p5 = self.backbone(x)
            f3, f4, f5 = self.fpn(p3, p4, p5)
            o3 = self.head3(f3)
            o4 = self.head4(f4)
            o5 = self.head5(f5)
            return [o3, o4, o5]
    
    				
    			

    15.6 yolo/assigner.py (asignación de objetivos)

    				
    					import torch
    
    def build_targets(
        targets,           # list of length B, each: Tensor [Ni, 5] -> (cls, x1, y1, x2, y2) in pixels
        anchors,           # per scale: list of (w,h) in pixels at input size
        strides,           # [8,16,32]
        img_size,          # int, e.g. 640
        num_classes,
        device
    ):
        """
        Returns per-scale target tensors:
          tbox: list of [B, A, S, S, 4] in xyxy pixels
          tobj: list of [B, A, S, S] (0/1)
          tcls: list of [B, A, S, S, C] one-hot
          indices: list of tuples for positives (b, a, gy, gx)
        """
        B = len(targets)
        out = []
        indices_all = []
    
        for scale_idx, (anc, stride) in enumerate(zip(anchors, strides)):
            S = img_size // stride
            A = len(anc)
    
            tbox = torch.zeros((B, A, S, S, 4), device=device)
            tobj = torch.zeros((B, A, S, S), device=device)
            tcls = torch.zeros((B, A, S, S, num_classes), device=device)
    
            indices = []
    
            anc_wh = torch.tensor(anc, device=device, dtype=torch.float32)  # [A,2]
    
            for b in range(B):
                if targets[b].numel() == 0:
                    continue
                gt = targets[b].to(device)
                cls = gt[:, 0].long()
                x1y1 = gt[:, 1:3]
                x2y2 = gt[:, 3:5]
                gxy = (x1y1 + x2y2) * 0.5
                gwh = (x2y2 - x1y1).clamp(min=1.0)
    
                # pick best anchor by IoU of width/height (approx)
                # IoU(wh) = min(w)/max(w) * min(h)/max(h)
                wh = gwh[:, None, :]  # [N,1,2]
                min_wh = torch.minimum(wh, anc_wh[None, :, :])
                max_wh = torch.maximum(wh, anc_wh[None, :, :])
                iou_wh = (min_wh[..., 0] / max_wh[..., 0]) * (min_wh[..., 1] / max_wh[..., 1])  # [N,A]
                best_a = torch.argmax(iou_wh, dim=1)  # [N]
    
                # grid cell
                gx = (gxy[:, 0] / stride).clamp(min=0, max=S-1e-3)
                gy = (gxy[:, 1] / stride).clamp(min=0, max=S-1e-3)
                gi = gx.long()
                gj = gy.long()
    
                for i in range(gt.shape[0]):
                    a = best_a[i].item()
                    x1, y1, x2, y2 = gt[i, 1:].tolist()
    
                    # assign
                    tobj[b, a, gj[i], gi[i]] = 1.0
                    tbox[b, a, gj[i], gi[i]] = torch.tensor([x1, y1, x2, y2], device=device)
                    tcls[b, a, gj[i], gi[i], cls[i]] = 1.0
                    indices.append((b, a, gj[i].item(), gi[i].item()))
    
            out.append((tbox, tobj, tcls))
            indices_all.append(indices)
    
        return out, indices_all
    
    				
    			

    15.7 yolo/loss.py

    				
    					import torch
    import torch.nn as nn
    from .box_ops import xywh_to_xyxy, ciou_loss_xyxy
    
    class YOLOLoss(nn.Module):
        def __init__(self, anchors, strides, num_classes, img_size,
                     lambda_box=7.5, lambda_obj=1.0, lambda_cls=1.0,
                     obj_pos_weight=1.0, obj_neg_weight=0.5):
            super().__init__()
            self.anchors = anchors
            self.strides = strides
            self.num_classes = num_classes
            self.img_size = img_size
    
            self.lambda_box = lambda_box
            self.lambda_obj = lambda_obj
            self.lambda_cls = lambda_cls
    
            self.bce = nn.BCEWithLogitsLoss(reduction="none")
            self.obj_pos_weight = obj_pos_weight
            self.obj_neg_weight = obj_neg_weight
    
        def decode_scale(self, pred, scale_idx):
            """
            pred: [B, A*(5+C), S, S]
            returns:
              boxes_xyxy: [B, A, S, S, 4] in pixels
              obj_logit:  [B, A, S, S]
              cls_logit:  [B, A, S, S, C]
            """
            B, _, S, _ = pred.shape
            A = len(self.anchors[scale_idx])
            C = self.num_classes
            stride = self.strides[scale_idx]
    
            pred = pred.view(B, A, 5 + C, S, S).permute(0, 1, 3, 4, 2).contiguous()
            # [B, A, S, S, 5+C]
            tx_ty = pred[..., 0:2]
            tw_th = pred[..., 2:4]
            obj = pred[..., 4]
            cls = pred[..., 5:]
    
            # grid
            gy, gx = torch.meshgrid(torch.arange(S, device=pred.device),
                                    torch.arange(S, device=pred.device), indexing="ij")
            grid = torch.stack([gx, gy], dim=-1).float()  # [S,S,2]
    
            # anchors
            anc = torch.tensor(self.anchors[scale_idx], device=pred.device).float()  # [A,2]
            anc = anc.view(1, A, 1, 1, 2)
    
            # decode center
            pxy = (tx_ty.sigmoid() + grid.view(1, 1, S, S, 2)) * stride  # pixels
            # decode wh
            pwh = (tw_th.exp() * anc)  # pixels
    
            boxes_xywh = torch.cat([pxy, pwh], dim=-1)
            boxes_xyxy = xywh_to_xyxy(boxes_xywh)
            return boxes_xyxy, obj, cls
    
        def forward(self, preds, targets_per_scale):
            """
            preds: list of 3 scale outputs
            targets_per_scale: list of (tbox, tobj, tcls) per scale
            """
            total_box = torch.tensor(0.0, device=preds[0].device)
            total_obj = torch.tensor(0.0, device=preds[0].device)
            total_cls = torch.tensor(0.0, device=preds[0].device)
    
            for s, pred in enumerate(preds):
                tbox, tobj, tcls = targets_per_scale[s]
                pbox, pobj_logit, pcls_logit = self.decode_scale(pred, s)
    
                # objectness loss with weighting
                obj_loss = self.bce(pobj_logit, tobj)
                w = torch.where(tobj > 0.5,
                                torch.full_like(obj_loss, self.obj_pos_weight),
                                torch.full_like(obj_loss, self.obj_neg_weight))
                total_obj = total_obj + (obj_loss * w).mean()
    
                # positives mask
                pos = tobj > 0.5
                if pos.any():
                    # box loss CIoU
                    pbox_pos = pbox[pos]
                    tbox_pos = tbox[pos]
                    box_loss = ciou_loss_xyxy(pbox_pos, tbox_pos).mean()
                    total_box = total_box + box_loss
    
                    # class loss
                    cls_loss = self.bce(pcls_logit[pos], tcls[pos]).mean()
                    total_cls = total_cls + cls_loss
    
            loss = self.lambda_box * total_box + self.lambda_obj * total_obj + self.lambda_cls * total_cls
            return loss, {"box": total_box.detach(), "obj": total_obj.detach(), "cls": total_cls.detach()}
    
    				
    			

    15.8 yolo/utils.py

    				
    					import torch
    
    def set_seed(seed=42):
        import random, numpy as np
        random.seed(seed)
        np.random.seed(seed)
        torch.manual_seed(seed)
        torch.cuda.manual_seed_all(seed)
    
    class AverageMeter:
        def __init__(self):
            self.sum = 0.0
            self.count = 0
    
        def update(self, v, n=1):
            self.sum += float(v) * n
            self.count += n
    
        @property
        def avg(self):
            return self.sum / max(1, self.count)
    
    				
    			

    15.9 yolo/transforms.py (buzón simple + volteo)

    				
    					import torch
    import torchvision.transforms.functional as TF
    
    def letterbox(image, boxes_xyxy, new_size=640):
        """
        image: PIL or Tensor [C,H,W]
        boxes_xyxy: Tensor [N,4] in pixels
        returns resized/padded image and transformed boxes
        """
        if not torch.is_tensor(image):
            image = TF.to_tensor(image)
        c, h, w = image.shape
    
        scale = min(new_size / h, new_size / w)
        nh, nw = int(round(h * scale)), int(round(w * scale))
        image_resized = TF.resize(image, [nh, nw])
    
        pad_h = new_size - nh
        pad_w = new_size - nw
        top = pad_h // 2
        left = pad_w // 2
    
        image_padded = torch.zeros((c, new_size, new_size), dtype=image.dtype)
        image_padded[:, top:top+nh, left:left+nw] = image_resized
    
        if boxes_xyxy.numel() > 0:
            boxes = boxes_xyxy.clone()
            boxes *= scale
            boxes[:, [0, 2]] += left
            boxes[:, [1, 3]] += top
        else:
            boxes = boxes_xyxy
    
        return image_padded, boxes
    
    def random_hflip(image, boxes_xyxy, p=0.5):
        if torch.rand(()) > p:
            return image, boxes_xyxy
        c, h, w = image.shape
        image = torch.flip(image, dims=[2])  # flip width
        boxes = boxes_xyxy.clone()
        if boxes.numel() > 0:
            x1 = boxes[:, 0].clone()
            x2 = boxes[:, 2].clone()
            boxes[:, 0] = (w - x2)
            boxes[:, 2] = (w - x1)
        return image, boxes
    
    				
    			

    15.10 yolo/data.py (esqueleto de conjunto de datos personalizado)

    				
    					import os
    import torch
    from torch.utils.data import Dataset
    from PIL import Image
    from .transforms import letterbox, random_hflip
    
    class DetectionDataset(Dataset):
        """
        Expects a list of samples where each sample has:
          - image_path
          - annotations: list of [cls, x1, y1, x2, y2] in pixels
        You can write adapters to load COCO or YOLO txt into this format.
        """
        def __init__(self, samples, img_size=640, augment=True):
            self.samples = samples
            self.img_size = img_size
            self.augment = augment
    
        def __len__(self):
            return len(self.samples)
    
        def __getitem__(self, idx):
            s = self.samples[idx]
            img = Image.open(s["image_path"]).convert("RGB")
            ann = s.get("annotations", [])
            if len(ann) > 0:
                target = torch.tensor(ann, dtype=torch.float32)  # [N,5]
            else:
                target = torch.zeros((0,5), dtype=torch.float32)
    
            cls = target[:, 0:1]
            boxes = target[:, 1:5]
    
            img, boxes = letterbox(img, boxes, self.img_size)
            if self.augment:
                img, boxes = random_hflip(img, boxes, p=0.5)
    
            # normalize image
            img = img.clamp(0, 1)
    
            # pack back
            if boxes.numel() > 0:
                target = torch.cat([cls, boxes], dim=1)
            else:
                target = torch.zeros((0,5), dtype=torch.float32)
    
            return img, target
    
    def collate_fn(batch):
        images, targets = zip(*batch)
        images = torch.stack(images, dim=0)
        # targets remains list[Tensor]
        return images, list(targets)
    
    				
    			

    15.11 train.py (bucle de entrenamiento de extremo a extremo)

    				
    					import torch
    from torch.utils.data import DataLoader
    
    from yolo.model import YOLO
    from yolo.loss import YOLOLoss
    from yolo.assigner import build_targets
    from yolo.utils import set_seed, AverageMeter
    from yolo.data import DetectionDataset, collate_fn
    
    def train_one_epoch(model, loss_fn, loader, optimizer, device, anchors, strides, img_size, num_classes, scaler=None):
        model.train()
        meter = AverageMeter()
    
        for images, targets_list in loader:
            images = images.to(device)
    
            # build targets per scale
            targets_per_scale, _ = build_targets(
                targets_list, anchors=anchors, strides=strides,
                img_size=img_size, num_classes=num_classes, device=device
            )
            targets_per_scale = [(tbox, tobj, tcls) for (tbox, tobj, tcls) in targets_per_scale]
    
            optimizer.zero_grad(set_to_none=True)
    
            if scaler is not None:
                with torch.cuda.amp.autocast():
                    preds = model(images)
                    loss, logs = loss_fn(preds, targets_per_scale)
                scaler.scale(loss).backward()
                scaler.step(optimizer)
                scaler.update()
            else:
                preds = model(images)
                loss, logs = loss_fn(preds, targets_per_scale)
                loss.backward()
                optimizer.step()
    
            meter.update(loss.item(), n=images.size(0))
    
        return meter.avg
    
    def main():
        set_seed(42)
        device = "cuda" if torch.cuda.is_available() else "cpu"
    
        img_size = 640
        num_classes = 80  # COCO example
        strides = [8, 16, 32]
        anchors = [
            [(10,13),(16,30),(33,23)],
            [(30,61),(62,45),(59,119)],
            [(116,90),(156,198),(373,326)]
        ]
    
        # TODO: load your samples list here
        samples = []  # [{"image_path": "...", "annotations": [[cls,x1,y1,x2,y2], ...]}, ...]
    
        ds = DetectionDataset(samples, img_size=img_size, augment=True)
        loader = DataLoader(ds, batch_size=8, shuffle=True, num_workers=4, pin_memory=True, collate_fn=collate_fn)
    
        model = YOLO(num_classes=num_classes, anchors=anchors, base=32).to(device)
        loss_fn = YOLOLoss(anchors, strides, num_classes, img_size).to(device)
    
        optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4, weight_decay=1e-4)
        scaler = torch.cuda.amp.GradScaler() if device == "cuda" else None
    
        for epoch in range(1, 101):
            avg_loss = train_one_epoch(model, loss_fn, loader, optimizer, device, anchors, strides, img_size, num_classes, scaler)
            print(f"Epoch {epoch:03d} | loss={avg_loss:.4f}")
    
        torch.save(model.state_dict(), "yolo_scratch.pt")
    
    if __name__ == "__main__":
        main()
    
    				
    			

    16) Inferencia: decodificación + NMS (canalización de predicción)

    16.1 Asistente de decodificación (añadir a yolo/metrics.py or yolo/utils.py)

    				
    					import torch
    from .box_ops import xywh_to_xyxy
    from .nms import batched_nms_xyxy
    
    @torch.no_grad()
    def decode_predictions(preds, anchors, strides, num_classes, conf_thresh=0.25, iou_thresh=0.5):
        """
        preds: list of 3 tensors [B, A*(5+C), S, S]
        returns per image: boxes [M,4], scores [M], labels [M]
        """
        outputs = []
        for b in range(preds[0].shape[0]):
            boxes_all = []
            scores_all = []
            labels_all = []
    
            for s, p in enumerate(preds):
                B, _, S, _ = p.shape
                A = len(anchors[s])
                C = num_classes
                stride = strides[s]
    
                x = p[b:b+1].view(1, A, 5+C, S, S).permute(0,1,3,4,2).contiguous()[0]  # [A,S,S,5+C]
                tx_ty = x[..., 0:2]
                tw_th = x[..., 2:4]
                obj_logit = x[..., 4]
                cls_logit = x[..., 5:]
    
                gy, gx = torch.meshgrid(torch.arange(S, device=p.device),
                                        torch.arange(S, device=p.device), indexing="ij")
                grid = torch.stack([gx, gy], dim=-1).float()  # [S,S,2]
    
                anc = torch.tensor(anchors[s], device=p.device).float().view(A,1,1,2)
    
                pxy = (tx_ty.sigmoid() + grid) * stride
                pwh = tw_th.exp() * anc
    
                boxes_xywh = torch.cat([pxy, pwh], dim=-1)  # [A,S,S,4]
                boxes_xyxy = xywh_to_xyxy(boxes_xywh)
    
                obj = obj_logit.sigmoid()  # [A,S,S]
                cls_prob = cls_logit.sigmoid()  # [A,S,S,C]
                # combine: per-class confidence = obj * cls_prob
                conf = obj.unsqueeze(-1) * cls_prob  # [A,S,S,C]
    
                conf = conf.view(-1, C)
                boxes = boxes_xyxy.view(-1, 4)
    
                scores, labels = conf.max(dim=1)
                keep = scores > conf_thresh
                boxes_all.append(boxes[keep])
                scores_all.append(scores[keep])
                labels_all.append(labels[keep])
    
            if boxes_all:
                boxes = torch.cat(boxes_all, dim=0)
                scores = torch.cat(scores_all, dim=0)
                labels = torch.cat(labels_all, dim=0)
    
                keep = batched_nms_xyxy(boxes, scores, labels, iou_thresh=iou_thresh)
                outputs.append((boxes[keep], scores[keep], labels[keep]))
            else:
                outputs.append((torch.zeros((0,4)), torch.zeros((0,)), torch.zeros((0,), dtype=torch.long)))
    
        return outputs
    
    				
    			

    17) Evaluador mAP (lógica central)

    Una correcta implementación de mAP es larga; aquí hay un enfoque de evaluador limpio y mínimo:

    • Recopila predicciones por imagen: cuadros, puntuaciones, etiquetas

    • Recopilar GT por imagen: cajas, etiquetas

    • Para cada clase:

      • Ordenar predicciones por puntuación

      • Marcar TP/FP utilizando la mejor coincidencia de IoU por encima del umbral (y solo coincidir con un GT una vez)

      • Calcular la curva de precisión-recuperación

      • Calcular AP mediante integración numérica

    • Promedio entre clases → mAP

    Si desea mAP@[.5:.95] estilo COCO, repita lo anterior en múltiples umbrales de IoU y promedio.

    Si quieres, puedo pegar un archivo completo y listo para ejecutar. metrics.py Con mAP@0.5 y COCO mAP en un solo archivo (son unos cientos de líneas). Para facilitar la lectura, la guía principal se centra en la canalización YOLO.


    18) Capacitación sobre COCO (apuntes prácticos)

    Para entrenar en COCO de manera efectiva:

    • Utilice una columna vertebral más fuerte y un lote más grande si es posible.

    • Utilice entrenamiento multiescala (cambie aleatoriamente el tamaño de entrada por lote)

    • Utilice el calentamiento para LR en las primeras ~1–3 épocas

    • Utilice los pesos EMA para la evaluación

    • Pendiente de:

      • Pérdida explosiva de objetividad (a menudo, error de asignación de objetivos)

      • casi cero positivos (desajuste entre anclajes y pasos)

      • Cajas que se mueven fuera de la imagen (error de decodificación)


    19) Entrenamiento en un conjunto de datos personalizado (el flujo de trabajo correcto más rápido)

    1. Seleccione el tamaño de entrada del modelo: 640 es común para una línea base

    2. Convertir anotaciones en píxeles XYXY absolutos

    3. Visualizar cuadros en imágenes antes del entrenamiento

    4. Empezar con:

      • sin aumento elegante

      • modelo pequeño

      • sobreajuste en 20 imágenes

    5. Si se sobreajusta, aumente la escala:

      • mas datos

      • aumentos

      • mejor columna vertebral/cuello


    20) Lista de verificación de errores comunes (ahorra horas)

    • Las cajas tienen un formato incorrecto (XYWH contra XYXY)

    • Cuadros normalizados pero tratados como píxeles (o viceversa)

    • Objetivos asignados a una escala incorrecta (desajuste de zancada)

    • Los tamaños de anclaje no coinciden con el tamaño de entrada (anclas para 416 pero entrenamiento en 640)

    • Indexación x/y intercambiada (gx vs gy en indexación de tensores)

    • Olvidé fijar los índices de la cuadrícula

    • NMS aplicado antes de convertir a XYXY

    • Evaluación de mAP que empareja múltiples preds con el mismo GT

    Implementar YOLO desde cero en PyTorch es una de las mejores formas de comprender verdaderamente la detección de objetos moderna, porque estás obligado a conectar cada parte móvil: cómo las etiquetas se convierten en objetivos de entrenamiento, cómo las predicciones se convierten en cuadros reales, por qué existen los anclajes y qué es lo que la objetividad está aprendiendo realmente.

    Al finalizar esta compilación, deberías tener un detector completo y funcional con:

    • Un modelo multiescala estilo YOLO (columna vertebral + cuello + cabezas de detección)

    • Una correcta canalización de asignación de objetivos (verdad fundamental → celda de cuadrícula + ancla)

    • Una configuración de pérdida estable (CIoU/GIoU para cajas + BCE para objetividad y clases)

    • Una ruta de inferencia adecuada (decodificación → filtrado de confianza → NMS)

    • Una ruta clara hacia la evaluación de grado de producción (mAP@0.5 y COCO mAP@[.5:.95])

    • Una estructura de repositorio que puedes ampliar a un proyecto real

    La conclusión más importante es que YOLO no es “mágico”: es un sistema cuidadosamente diseñado de transformaciones de coordenadas consistentes, Emparejamiento responsable de anclas y pérdidas equilibradasSi alguna de esas piezas no coincide (formato de caja incorrecto, paso incorrecto, anclajes no coincidentes, índices x/y invertidos), el aprendizaje se desmorona. Pero cuando todo encaja, el modelo se entrena sin problemas y escala correctamente.

    Visite nuestro servicio de anotación de datos


    Esto cerrará en 20 segundos