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

  1. (Fácil) Implemente box_iou e rode-o contra torchvision.ops.box_iou em 1.000 pares de caixas aleatórios. Verifique que a diferença absoluta máxima fica abaixo de 1e-6.
  2. (Médio) Porte yolo_loss para uma versão que usa perda de caixa CIoU em 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.
  3. (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

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