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 derechaXYWH:
(cx, cy, w, h)centro y tamañoNormalizado: coordenadas en
[0, 1]relativo al tamaño de la imagenAbsoluto: 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, thLogit de objetividad:
tologits 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) / Sby = (sigmoid(ty) + cy) / Sbw = (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:
Remodelar las salidas por escala a
(B, A, S, S, 5+C)Aplicar sigmoide a los desplazamientos centrales + objetividad (y a menudo problemas de clase)
Convertir a XYXY en coordenadas de píxeles
Aplanar todas las escalas en una lista de cuadros candidatos
Filtrar por umbral de confianza
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:
Determinar qué escala(s) debería(n) manejarlo (según el tamaño/coincidencia de anclaje).
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).
Seleccione el mejor ancla (o los mejores k anclas).
Calcular el índice de celda de la cuadrícula desde el centro GT.
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 atxissigmoid^-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 fraccionariaspredecir
pwh = exp(tw,th)*anchory 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:
Pérdida de caja:CIoU/GIoU entre el cuadro previsto y el cuadro GT en las ubicaciones responsables
Pérdida de objetividad: BCEWithLogits sobre logit de objetividad
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.pyCon 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)
Seleccione el tamaño de entrada del modelo: 640 es común para una línea base
Convertir anotaciones en píxeles XYXY absolutos
Visualizar cuadros en imágenes antes del entrenamiento
Empezar con:
sin aumento elegante
modelo pequeño
sobreajuste en 20 imágenes
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.