Фаза 04 · урок 06

Обнаружение объектов — YOLO с нуля

Цель урока: Классификация говорит: «на этом изображении собака». Обнаружение говорит: «собака находится в пикселях (112, 40, 280, 210), кошка — в (400, 180, 560, 310), а больше в кадре ничего нет». Это единственное структурное изменение —…

Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.

Курс
AI Engineering from Scratch
Фаза
Компьютерное зрение
Чтение
15 мин.
Проверено
Содержание урока
  1. Цели обучения
  2. Проблема
  3. Концепция
  4. Обнаружение как плотное предсказание
  5. Почему сетки и якоря
  6. Декодирование предсказаний
  7. IoU
  8. Подавление немаксимумов
  9. Потеря
  10. Метрики обнаружения
  11. Соберите сами
  12. Шаг 1: IoU
  13. Шаг 2: Подавление немаксимумов
  14. Шаг 3: Кодирование и декодирование рамок
  15. Шаг 4: Минимальная голова YOLO
  16. Шаг 5: Назначение ground truth
  17. Шаг 6: Три потери
  18. Шаг 7: Конвейер инференса
  19. Используйте
  20. Подготовьте к поставке
  21. Упражнения
  22. Ключевые термины
  23. Дополнительные материалы

Обнаружение — это классификация плюс регрессия, выполняемые в каждой позиции карты признаков, а затем очищаемые подавлением немаксимумов.

Тип: Сборка Языки: Python Предварительные требования: Фаза 4, урок 03 (CNN), фаза 4, урок 04 (Классификация изображений), фаза 4, урок 05 (Transfer Learning) Время: ~75 минут

Цели обучения

  • Объяснить конструкцию с сеткой и якорями, которая превращает обнаружение в задачу плотного предсказания, и назвать смысл каждого числа в выходном тензоре
  • Вычислить Intersection-over-Union для рамок и реализовать подавление немаксимумов с нуля
  • Собрать минимальную голову в стиле YOLO поверх предобученного backbone, включая потери классификации, objectness и регрессии рамок
  • Прочитать строку метрик обнаружения (precision@0.5, recall, mAP@0.5, mAP@0.5:0.95) и выбрать следующий параметр для настройки

Проблема

Классификация говорит: «на этом изображении собака». Обнаружение говорит: «собака находится в пикселях (112, 40, 280, 210), кошка — в (400, 180, 560, 310), а больше в кадре ничего нет». Это единственное структурное изменение — предсказание переменного числа размеченных рамок вместо одной метки на изображение — лежит в основе каждой автономной системы, каждого продукта видеонаблюдения, каждого парсера макета документов и каждой производственной линии машинного зрения.

В обнаружении сразу проявляются все инженерные компромиссы компьютерного зрения. Нужны точные рамки (голова регрессии), правильный класс для каждой рамки (классификация), понимание моделью, когда обнаруживать нечего (оценка objectness), и ровно одно предсказание на реальный объект (подавление немаксимумов). Упустите любой из этих компонентов — и конвейер либо пропустит объекты, либо выдаст галлюцинированные рамки, либо предскажет один объект пятнадцать раз в слегка разных позициях.

YOLO (You Only Look Once, Redmon et al. 2016) сделала всё это работающим в реальном времени одним прямым проходом свёрточной сети; те же структурные решения до сих пор образуют основу современных детекторов (YOLOv8, YOLOv9, YOLO-NAS, RT-DETR). Освойте ядро — и каждый вариант станет перестановкой тех же компонентов.

Концепция

Обнаружение как плотное предсказание

Классификатор выдаёт C чисел на изображение. Детектор в стиле YOLO выдаёт (S x S x (5 + C)) чисел на изображение, где S — пространственный размер сетки.

Диаграмма к уроку «Обнаружение объектов — YOLO с нуля»

Каждая из S * S ячеек сетки предсказывает B рамок. Для каждой рамки:

  • 4 числа описывают геометрию: tx, ty, tw, th.
  • 1 число — оценка objectness: «есть ли объект с центром в этой ячейке?»
  • C чисел — вероятности классов.

Итого на ячейку: B * (5 + C). Для VOC при S=13, B=2, C=20 это 50 чисел на ячейку.

Почему сетки и якоря

Простая регрессия предсказывала бы (x, y, w, h) для каждого объекта как абсолютные координаты. Для свёрточной сети это трудно, поскольку перенос изображения не должен сдвигать все предсказания на одну и ту же величину: каждый объект пространственно привязан. Сетка решает это, назначая каждую ground-truth рамку ячейке, в которую попадает её центр; только эта ячейка отвечает за объект.

Якоря решают вторую проблему. Свёртка 3x3 не может легко регрессировать рамку шириной 500 пикселей по ячейке признаков с рецептивным полем 16 пикселей. Вместо этого для каждой ячейки заранее задаются B форм prior-рамок (якорей) и предсказываются небольшие смещения от каждого якоря. Модель учится выбрать подходящий якорь и подправить его, а не регрессировать из ничего.

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).

Современные детекторы часто используют FPN с разными наборами якорей для каждого разрешения: маленькие якоря на неглубоких картах высокого разрешения, большие — на глубоких картах низкого разрешения. Идея та же, масштабов больше.

Декодирование предсказаний

Необработанные tx, ty, tw, th — не координаты рамки; это целевые значения регрессии, которые перед отрисовкой нужно преобразовать:

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 удерживает смещения центра внутри ячейки. exp позволяет ширине свободно масштабироваться от якоря без смены знака. stride переводит координаты сетки обратно в пиксели. Этот шаг декодирования одинаков во всех версиях YOLO, начиная с v2.

IoU

Универсальная метрика сходства двух рамок в обнаружении:

IoU(A, B) = area(A intersect B) / area(A union B)

IoU = 1 означает полное совпадение, IoU = 0 — отсутствие пересечения. IoU между предсказанием и ground-truth рамкой определяет, считается ли предсказание истинно положительным (обычно IoU >= 0.5). IoU двух предсказаний используется NMS для удаления дубликатов.

Подавление немаксимумов

Свёрточная сеть, обученная на соседних якорях, часто предсказывает перекрывающиеся рамки для одного объекта. NMS оставляет предсказание с наивысшей уверенностью и удаляет все остальные с IoU выше порога.

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

Типичный порог для обнаружения объектов — 0.45. Новые детекторы заменяют стандартный NMS на soft-NMS, DIoU-NMS или обучают подавление напрямую (RT-DETR), но структурное назначение остаётся тем же.

Потеря

Потеря YOLO — это три потери, сложенные с весами:

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)

Только ячейки с объектом вносят вклад в потери регрессии рамок и классификации. Ячейки без объектов вносят вклад только в потерю objectness, обучая модель молчать. lambda_noobj обычно мало (~0.5), поскольку подавляющее большинство ячеек пусты и иначе доминировали бы в общей потере.

Современные варианты заменяют MSE-потерю рамок на CIoU / DIoU (которые оптимизируют IoU напрямую), используют focal loss при дисбалансе классов и балансируют objectness с quality focal loss. Трёхкомпонентная структура остаётся неизменной.

Метрики обнаружения

Точность классификации не переносится на обнаружение. Нужны четыре другие числа:

  • Precision@IoU=0.5 — сколько предсказаний, посчитанных положительными, действительно верны.
  • Recall@IoU=0.5 — сколько реальных объектов мы нашли.
  • AP@0.5 — площадь под кривой precision–recall при пороге IoU 0.5; одно число на класс.
  • mAP@0.5:0.95 — среднее AP по порогам IoU 0.5, 0.55, …, 0.95. Метрика COCO; самая строгая и информативная.

Сообщайте все четыре. Детектор, сильный по mAP@0.5, но слабый по mAP@0.5:0.95, грубо локализует объекты, но не делает это точно; исправляйте лучшей функцией потерь для регрессии рамок. Детектор с высокой precision и низким recall слишком консервативен; уменьшите порог уверенности или увеличьте вес objectness.

Соберите сами

Шаг 1: IoU

Рабочая лошадка всего урока. Работает с двумя массивами рамок в формате (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)

Возвращает матрицу (N_a, N_b) попарных IoU. Чтобы сравнить с одной ground-truth рамкой, придайте одному из массивов форму (1, 4).

Шаг 2: Подавление немаксимумов

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(boxesi, boxes[rest])[0]
        order = rest[ious <= iou_threshold]
    return np.array(keep, dtype=np.int64)

Детерминированная реализация с O(N log N) из-за сортировки; на одинаковых входах совпадает с поведением torchvision.ops.nms.

Шаг 3: Кодирование и декодирование рамок

Преобразуйте пиксельные координаты в цели (tx, ty, tw, th), которые сеть действительно регрессирует, и обратно.

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))

Проверка: закодируйте рамку, затем декодируйте — должна вернуться почти исходная (с поправкой на то, что обратная к sigmoid функция не идеально обратима, когда tx находится вне диапазона после sigmoid).

Шаг 4: Минимальная голова YOLO

Одна свёртка 1x1 по карте признаков с изменением формы в (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

Форма выхода: (N, H, W, num_anchors, 5 + C). Последняя размерность содержит [tx, ty, tw, th, obj, cls_0, ..., cls_{C-1}].

Шаг 5: Назначение ground truth

Для каждой ground-truth рамки определите, какая пара (ячейка, якорь) отвечает за неё.

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

Выбор якоря — это «лучший IoU формы с ground truth»: дешёвый прокси-метод, соответствующий назначению в YOLOv2/v3. В v5 и позже применяют более сложные стратегии (task-aligned matching, dynamic k), уточняющие ту же идею.

Шаг 6: Три потери

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()}

Пять гиперпараметров, которые любой учебник YOLO либо жёстко задаёт, либо перебирает. Важны соотношения: lambda_coord=5, lambda_noobj=0.5 повторяют исходную статью YOLOv1 и всё ещё служат разумным значением по умолчанию.

Шаг 7: Конвейер инференса

Декодируйте необработанный выход головы, примените sigmoid/exp, отсейте по objectness и 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]

Это полный путь оценки: голова -> декодирование -> порог -> NMS.

Используйте

torchvision.models.detection поставляет продакшен-детекторы с той же концептуальной структурой. Загрузка предобученной модели занимает три строки.

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}")

Для конвейеров инференса в реальном времени стандартом является ultralytics (YOLOv8/v9): from ultralytics import YOLO; model = YOLO('yolov8n.pt'); model(img). Модель самостоятельно выполняет декодирование и NMS и возвращает ту же тройку boxes / scores / labels, которую вы собрали выше.

Подготовьте к поставке

Этот урок создаёт:

  • outputs/prompt-detection-metric-reader.md — промпт, превращающий строку precision, recall, AP, mAP@0.5:0.95 в однострочный диагноз и единственный наиболее полезный следующий эксперимент.
  • outputs/skill-anchor-designer.md — навык, который по датасету ground-truth рамок запускает k-means на (w, h) и возвращает наборы якорей на уровень FPN вместе со статистикой покрытия, необходимой для выбора правильного числа якорей.

Упражнения

  1. (Легко) Реализуйте box_iou и сравните его с torchvision.ops.box_iou на 1 000 случайных пар рамок. Убедитесь, что максимальная абсолютная разность меньше 1e-6.
  2. (Средне) Перенесите yolo_loss в версию, использующую CIoU-потерю рамок вместо MSE. На синтетическом наборе из 100 изображений покажите, что CIoU за то же число эпох сходится к лучшему итоговому mAP@0.5:0.95, чем MSE.
  3. (Сложно) Реализуйте многомасштабный инференс: подайте одно изображение в модель в трёх разрешениях, объедините предсказания рамок и в конце выполните один NMS. Измерьте прирост mAP относительно одомасштабного инференса на отложенном наборе.

Ключевые термины

Термин Как говорят Что это на самом деле означает
Якорь «Prior-рамка» Заранее определённая форма рамки в каждой ячейке сетки, от которой сеть предсказывает смещения вместо абсолютных координат
IoU «Перекрытие» Intersection-over-union двух рамок; универсальная мера сходства в обнаружении
NMS «Удаление дубликатов» Жадный алгоритм, оставляющий предсказания с наивысшей оценкой и удаляющий перекрывающиеся выше порога
Objectness «Есть ли здесь что-то» Скаляр на якорь и ячейку, предсказывающий, центрирован ли объект в этой ячейке
Шаг сетки «Коэффициент downsample» Число пикселей на ячейку сетки; у входа 416 пикселей и головы с сеткой 13 шаг равен 32
mAP «Mean average precision» Среднее площади под кривой precision–recall по классам и (для COCO) порогам IoU
AP@0.5 «PASCAL VOC AP» Average precision с порогом IoU 0.5; менее строгая версия метрики
mAP@0.5:0.95 «COCO AP» Среднее по порогам IoU 0.5..0.95 с шагом 0.05; строгая версия и текущий стандарт сообщества

Дополнительные материалы


Источник: Object Detection — YOLO from Scratch 04.05 — Transfer Learning и тонкая настройка · Фаза 04 — Компьютерное зрение · 04.07 — Семантическая сегментация — U-Net · Полный каталог