Фаза 04 · урок 06

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

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

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

Курс
AI Engineering с нуля
Фаза
Компьютерное зрение
Чтение
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 · Полный каталог