Phase 04 - Lesson 06
Detecção de Objetos — YOLO do Zero
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Detecção é classificação mais regressão, executada em cada posição de um mapa de características e depois limpa com supressão não-máxima.
Tipo: Build Linguagens: Python Pré-requisitos: Fase 4 Lição 03 (CNNs), Fase 4 Lição 04 (Classificação de Imagens), Fase 4 Lição 05 (Transfer Learning) Tempo: ~75 minutos
Objetivos de Aprendizagem
- Explicar o design de grade-e-âncora que transforma a detecção em um problema de predição densa e dizer o que cada número no tensor de saída significa
- Calcular a Interseção-sobre-União entre caixas e implementar a supressão não-máxima do zero
- Construir uma cabeça mínima no estilo YOLO sobre um backbone pré-treinado, incluindo as perdas de classificação, de objectness e de regressão de caixa
- Ler uma linha de métricas de detecção (precision@0.5, recall, mAP@0.5, mAP@0.5:0.95) e escolher qual botão girar em seguida
O Problema
A classificação diz "esta imagem é um cachorro". A detecção diz "há um cachorro nos pixels (112, 40, 280, 210), há um gato em (400, 180, 560, 310) e nada mais no quadro". Essa única mudança estrutural — prever um número variável de caixas rotuladas em vez de um rótulo por imagem — é aquilo de que todo sistema autônomo, todo produto de vigilância, todo parser de layout de documentos e toda linha de visão de fábrica depende.
A detecção também é onde todos os trade-offs de engenharia em visão aparecem de uma só vez. Você quer caixas precisas (cabeça de regressão), quer a classe certa para cada caixa (cabeça de classificação), quer que o modelo saiba quando não há nada a detectar (score de objectness) e quer exatamente uma predição por objeto real (supressão não-máxima). Erre qualquer um destes e o pipeline ou perde objetos, ou reporta caixas alucinadas, ou prevê o mesmo objeto quinze vezes em posições ligeiramente diferentes.
YOLO (You Only Look Once, Redmon et al. 2016) foi o design que fez tudo isso rodar em tempo real ao realizá-lo com uma única passagem direta de uma rede convolucional, e as mesmas decisões estruturais ainda são a espinha dorsal dos detectores modernos (YOLOv8, YOLOv9, YOLO-NAS, RT-DETR). Aprenda o núcleo e toda variante se torna uma reorganização das mesmas partes.
O Conceito
Detecção como predição densa
Um classificador produz C números por imagem. Um detector no estilo YOLO produz (S x S x (5 + C)) números por imagem, onde S é o tamanho da grade 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["Cabeça de detecção<br/>(convs 1x1)"]
HEAD --> OUT["Tensor de saída<br/>(13, 13, B * (5 + C))"]
OUT --> DEC["Decode<br/>(grade + sigmoid + exp)"]
DEC --> NMS["Supressão não-máxima"]
NMS --> RESULT["Caixas finais"]
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 uma das S * S células da grade prevê B caixas. Para cada caixa:
- 4 números descrevem a geometria:
tx, ty, tw, th. - 1 número é o score de objectness: "há um objeto centrado nesta célula?"
- C números são probabilidades de classe.
Total por célula: B * (5 + C). Para o VOC com S=13, B=2, C=20, são 50 números por célula.
Por que grades e âncoras
A regressão simples preveria (x, y, w, h) para cada objeto como uma coordenada absoluta. Isso é difícil para uma rede convolucional porque transladar a imagem não deveria transladar todas as predições pela mesma quantidade — cada objeto está espacialmente ancorado. A grade resolve isso atribuindo cada caixa de verdade-fundamental à célula da grade onde seu centro cai; apenas essa célula é responsável por aquele objeto.
As âncoras lidam com um segundo problema. Uma conv 3x3 não consegue facilmente regredir uma caixa de 500 pixels de largura a partir de uma célula de características com campo receptivo de 16 pixels. Em vez disso, pré-definimos B formas de caixa a priori (âncoras) por célula e prevemos pequenos deltas a partir de cada âncora. O modelo aprende a escolher a âncora certa e ajustá-la em vez de regredir do nada.
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).
Detectores modernos frequentemente usam FPN com conjuntos de âncoras diferentes por resolução — âncoras pequenas em mapas rasos de alta resolução, âncoras grandes em mapas profundos de baixa resolução. Mesma ideia, mais escalas.
Decodificando predições
Os valores brutos tx, ty, tw, th não são coordenadas de caixa; são alvos de regressão a serem transformados antes de plotar:
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 mantém os offsets do centro dentro da célula. exp permite que a largura escale livremente a partir da âncora sem inversão de sinal. stride reescala as coordenadas da grade de volta para pixels. Esta etapa de decode é a mesma em toda versão do YOLO desde a v2.
IoU
A métrica de similaridade universal da detecção entre duas caixas:
IoU(A, B) = area(A intersect B) / area(A union B)
IoU = 1 significa idêntico; IoU = 0 significa nenhuma sobreposição. A IoU entre a predição e a caixa de verdade-fundamental é o que decide se uma predição conta como verdadeiro positivo (tipicamente IoU >= 0.5). A IoU entre duas predições é o que a NMS usa para desduplicar.
Supressão não-máxima
Uma rede convolucional treinada em âncoras adjacentes frequentemente preverá caixas sobrepostas para o mesmo objeto. A NMS mantém a predição de maior confiança e apaga qualquer outra predição com IoU acima de um limiar.
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
Limiar típico: 0.45 para detecção de objetos. Detectores recentes substituem a NMS padrão por soft-NMS, DIoU-NMS, ou aprendem a supressão diretamente (RT-DETR), mas o propósito estrutural é o mesmo.
A perda
A perda do YOLO são três perdas somadas com 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)
Apenas células que contêm um objeto contribuem para as perdas de regressão de caixa e de classificação. Células sem objetos contribuem apenas para a perda de objectness (ensinando o modelo a permanecer silencioso). lambda_noobj geralmente é pequeno (~0.5) porque a vasta maioria das células está vazia e de outra forma dominaria a perda total.
Variantes modernas trocam a perda de caixa MSE por CIoU / DIoU (que otimizam a IoU diretamente), usam focal loss para desbalanceamento de classes e equilibram o objectness com quality focal loss. A estrutura de três componentes permanece inalterada.
Métricas de detecção
A acurácia não se transfere para a detecção. Quatro números que se transferem:
- Precision@IoU=0.5 — das predições contadas como positivas, quantas estão de fato corretas.
- Recall@IoU=0.5 — dos objetos reais, quantos encontramos.
- AP@0.5 — área da curva precision-recall no limiar de IoU 0.5; um número por classe.
- mAP@0.5:0.95 — média da AP sobre os limiares de IoU 0.5, 0.55, ..., 0.95. A métrica do COCO; a mais rigorosa e mais informativa.
Reporte os quatro. Um detector forte em mAP@0.5 mas fraco em mAP@0.5:0.95 está localizando grosseiramente mas não com precisão; corrija com uma melhor perda de regressão de caixa. Um detector com alta precision e baixo recall é conservador demais; reduza o limiar de confiança ou aumente o peso de objectness.
Construa
Passo 1: IoU
O carro-chefe de toda a lição. Funciona em dois arrays de caixas no 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)
Retorna uma matriz (N_a, N_b) de IoUs par a par. Use-a contra uma única caixa de verdade-fundamental fazendo um dos arrays ter shape (1, 4).
Passo 2: Supressão não-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)
Determinística, O(N log N) por causa da ordenação, e corresponde ao comportamento de torchvision.ops.nms em entradas idênticas.
Passo 3: Codificação e decodificação de caixas
Converta entre coordenadas de pixel e os alvos (tx, ty, tw, th) que a rede de fato regride.
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))
Teste: codifique uma caixa e depois decodifique — você deve recuperar algo muito próximo do original (até onde a inversa do sigmoid não é perfeitamente invertível quando tx não está na faixa pós-sigmoid).
Passo 4: Uma cabeça YOLO mínima
Uma conv 1x1 sobre um mapa de características, remodelando para (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 saída: (N, H, W, num_anchors, 5 + C). A última dimensão contém [tx, ty, tw, th, obj, cls_0, ..., cls_{C-1}].
Passo 5: Atribuição de verdade-fundamental
Para cada caixa de verdade-fundamental, decida qual (cell, anchor) é responsável.
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
A seleção de âncora é "melhor IoU de forma com a verdade-fundamental" — um proxy barato que corresponde à atribuição do YOLOv2/v3. A v5 e posteriores usam estratégias mais sofisticadas (task-aligned matching, dynamic k) que refinam a mesma ideia.
Passo 6: As três perdas
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 ou fixa no código ou varre. As proporções importam: lambda_coord=5, lambda_noobj=0.5 espelha o artigo original do YOLOv1 e ainda funciona como um default razoável.
Passo 7: Pipeline de inferência
Decodifique a saída bruta da cabeça, aplique sigmoid/exp, aplique limiar no objectness e 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]
Esse é o caminho completo de avaliação: cabeça -> decode -> limiar -> NMS.
Use
torchvision.models.detection entrega detectores de produção com a mesma estrutura conceitual. Carregar um modelo pré-treinado leva três linhas.
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 inferência em tempo real, ultralytics (YOLOv8/v9) é o padrão: from ultralytics import YOLO; model = YOLO('yolov8n.pt'); model(img). O modelo lida com a decodificação e a NMS internamente e retorna a mesma tripla boxes / scores / labels que você construiu acima.
Entregue
Esta lição produz:
outputs/prompt-detection-metric-reader.md— um prompt que transforma uma linhaprecision, recall, AP, mAP@0.5:0.95em um diagnóstico de uma linha e no único experimento seguinte mais útil.outputs/skill-anchor-designer.md— uma skill que, dado um dataset de caixas de verdade-fundamental, roda k-means em(w, h)e retorna conjuntos de âncoras por nível de FPN mais as estatísticas de cobertura que você precisa para escolher o número certo de âncoras.
Exercícios
- (Fácil) Implemente
box_ioue rode-o contratorchvision.ops.box_iouem 1.000 pares de caixas aleatórios. Verifique que a diferença absoluta máxima fica abaixo de1e-6. - (Médio) Porte
yolo_losspara uma versão que usa perda de caixaCIoUem vez de MSE. Mostre, em um dataset sintético de 100 imagens, que o CIoU converge para um mAP@0.5:0.95 final melhor que o MSE no mesmo número de épocas. - (Difícil) Implemente inferência multi-escala: alimente a mesma imagem em três resoluções pelo modelo, una as predições de caixa e rode uma única NMS no final. Meça o ganho de mAP versus a inferência de escala única em um conjunto reservado.
Termos-Chave
| Termo | O que as pessoas dizem | O que de fato significa |
|---|---|---|
| Anchor | "Box prior" | Uma forma de caixa pré-definida em cada célula da grade a partir da qual a rede prevê deltas em vez de coordenadas absolutas |
| IoU | "Sobreposição" | Interseção-sobre-união de duas caixas; a medida de similaridade universal em detecção |
| NMS | "Desduplicar" | Algoritmo guloso que mantém as predições de maior score e remove as sobrepostas acima de um limiar |
| Objectness | "Tem algo aqui" | Escalar por âncora e por célula que prevê se um objeto está centrado naquela célula |
| Grid stride | "Fator de downsample" | Pixels por célula da grade; uma entrada de 416-px com uma cabeça de grade 13 tem stride 32 |
| mAP | "Mean average precision" | Média da área sob a curva precision-recall, calculada sobre as classes e (para o COCO) sobre os limiares de IoU |
| AP@0.5 | "PASCAL VOC AP" | Average precision com limiar de IoU 0.5; a versão leniente da métrica |
| mAP@0.5:0.95 | "COCO AP" | Média sobre os limiares de IoU 0.5..0.95 com passo 0.05; a versão rigorosa e o padrão atual da comunidade |
Leitura Adicional
- YOLOv1: You Only Look Once (Redmon et al., 2016) — o artigo fundador; todo YOLO desde então é um refinamento desta estrutura
- YOLOv3 (Redmon & Farhadi, 2018) — o artigo que introduziu as cabeças multi-escala no estilo FPN; ainda o diagrama mais claro
- Documentação do Ultralytics YOLOv8 — a referência de produção atual; cobre formatos de dataset, augmentations e receitas de treinamento
- The Illustrated Guide to Object Detection (Jonathan Hui) — o melhor tour em linguagem simples por todo o zoológico de detectores; valiosíssimo para entender como DETR, RetinaNet, FCOS e YOLO se relacionam