Фаза 04 · урок 06
Обнаружение объектов — YOLO с нуля
Цель урока: Классификация говорит: «на этом изображении собака». Обнаружение говорит: «собака находится в пикселях (112, 40, 280, 210), кошка — в (400, 180, 560, 310), а больше в кадре ничего нет». Это единственное структурное изменение —…
Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.
Содержание урока
- Цели обучения
- Проблема
- Концепция
- Обнаружение как плотное предсказание
- Почему сетки и якоря
- Декодирование предсказаний
- IoU
- Подавление немаксимумов
- Потеря
- Метрики обнаружения
- Соберите сами
- Шаг 1: IoU
- Шаг 2: Подавление немаксимумов
- Шаг 3: Кодирование и декодирование рамок
- Шаг 4: Минимальная голова YOLO
- Шаг 5: Назначение ground truth
- Шаг 6: Три потери
- Шаг 7: Конвейер инференса
- Используйте
- Подготовьте к поставке
- Упражнения
- Ключевые термины
- Дополнительные материалы
Обнаружение — это классификация плюс регрессия, выполняемые в каждой позиции карты признаков, а затем очищаемые подавлением немаксимумов.
Тип: Сборка Языки: 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 — пространственный размер сетки.
Каждая из 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 вместе со статистикой покрытия, необходимой для выбора правильного числа якорей.
Упражнения
- (Легко) Реализуйте
box_iouи сравните его сtorchvision.ops.box_iouна 1 000 случайных пар рамок. Убедитесь, что максимальная абсолютная разность меньше1e-6. - (Средне) Перенесите
yolo_lossв версию, использующую CIoU-потерю рамок вместо MSE. На синтетическом наборе из 100 изображений покажите, что CIoU за то же число эпох сходится к лучшему итоговому mAP@0.5:0.95, чем MSE. - (Сложно) Реализуйте многомасштабный инференс: подайте одно изображение в модель в трёх разрешениях, объедините предсказания рамок и в конце выполните один 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; строгая версия и текущий стандарт сообщества |
Дополнительные материалы
- YOLOv1: You Only Look Once (Redmon et al., 2016) — основополагающая статья; все последующие YOLO уточняют эту структуру
- YOLOv3 (Redmon & Farhadi, 2018) — статья, представившая многомасштабные головы в стиле FPN; до сих пор содержит самую ясную схему
- Документация Ultralytics YOLOv8 — актуальный продакшен-источник; охватывает форматы датасетов, аугментации и рецепты обучения
- Illustrated Guide to Object Detection (Jonathan Hui) — лучший обзор всего семейства детекторов на простом английском; незаменим для понимания связи DETR, RetinaNet, FCOS и YOLO
Источник: Object Detection — YOLO from Scratch 04.05 — Transfer Learning и тонкая настройка · Фаза 04 — Компьютерное зрение · 04.07 — Семантическая сегментация — U-Net · Полный каталог