Рамки YOLO съехали после letterbox: как вернуть координаты на исходный кадр

Детектор находит коробку, но в веб-интерфейсе рамка рисуется ниже неё. На квадратной тестовой картинке всё совпадает. Ошибка появляется на широком кадре камеры после переноса инференса в собственный сервис. Это характерный момент, когда стоит проверить преобразование координат до изменения весов модели.

Перед запуском детектора изображение часто уменьшают с сохранением пропорций, а свободное место заполняют полями. Такой способ подготовки называют letterbox. Модель видит уже новый холст. Если её выходные координаты относятся к этому холсту, нарисовать их на исходном кадре без обратного преобразования нельзя.

Откуда берётся сдвиг

Возьмём кадр шириной 1280 и высотой 720 пикселей. После уменьшения вдвое получаем изображение 640×360. Чтобы поместить его в квадрат 640×640, добавляем сверху и снизу по 140 пикселей. Точка исходного изображения с координатами (100, 60) переходит в (50, 170): сначала масштабирование, затем сдвиг по вертикали.

При восстановлении порядок обратный: вычесть левое и верхнее поля, затем разделить координаты на масштаб. Простое умножение на два вернёт размер рамки, но сохранит лишний вертикальный сдвиг. Обе координаты y окажутся больше нужных на 280 пикселей.

В примере используем рамки xyxy: левый верхний и правый нижний углы. Границы считаем непрерывными, полуоткрытыми: правая граница может быть равна ширине изображения. Это отличается от индекса последнего пикселя массива.

import numpy as np

# Исходный кадр 1280x720 помещаем в холст 640x640.
h, w = 720, 1280
ratio = 0.5
left, top = 0, 140
# Полуоткрытые границы xyxy в пикселях исходного кадра.
original = np.array([[100, 60, 700, 500]], dtype=np.float64)
shift = np.array([left, top, left, top])
network_boxes = original * ratio + shift

def restore(boxes, ratio, left, top, width, height):
    if ratio <= 0:
        raise ValueError("Масштаб должен быть положительным")
    result = np.asarray(boxes, dtype=np.float64).copy()
    result -= [left, top, left, top]
    result /= ratio
    result[:, [0, 2]] = np.clip(result[:, [0, 2]], 0, width)
    result[:, [1, 3]] = np.clip(result[:, [1, 3]], 0, height)
    return result

restored = restore(network_boxes, ratio, left, top, w, h)
wrong = network_boxes / ratio
print("Вход модели:", network_boxes.tolist())
print("Без удаления padding:", wrong.tolist())
print("Исходный кадр:", restored.tolist())
np.testing.assert_allclose(restored, original)
assert not np.allclose(wrong, original)
# Рамка за пределами полезной области корректно обрезается.
clipped = restore([[-10, 130, 650, 510]], ratio, left, top, w, h)
np.testing.assert_allclose(clipped, [[0, 0, 1280, 720]])
assert restore(np.empty((0, 4)), ratio, left, top, w, h).shape == (0, 4)

Код выведет рамку на входе модели [50,170,350,390]. Ошибочное восстановление даст [100,340,700,780], правильное — исходные [100,60,700,500]. Проверка сравнивает все четыре координаты и дополнительно проверяет обрезку по границам кадра. Для запуска достаточно Python и NumPy; картинка и веса детектора не требуются.

Какие параметры сохранять в сервисе

Сохраняйте рядом с кадром его исходный размер, реальный масштаб и фактически добавленные поля слева и сверху. Эти значения должен вернуть тот этап, который готовил изображение. Восстанавливать их позже только по двум размерам опасно: поля могут быть несимметричными, размер холста — подогнанным под шаг сети, а увеличение небольших изображений — запрещённым настройкой.

Если при подготовке выполнялся обычный resize с разными коэффициентами по осям, нужен отдельный масштаб для x и y. Если выполнялся crop, в обратном преобразовании появляется смещение вырезанного участка. Не смешивайте эти варианты с показанной операцией. Перед вычислением также уточните формат выхода: нормализованные координаты сначала переводятся в пиксели того пространства, относительно которого они нормализованы.

Не выполните восстановление дважды

Высокоуровневый predict в Ultralytics возвращает объект результатов, где координаты рамок уже связаны с исходным изображением. Это особенно заметно у xyxyn: нормализация выполнена относительно исходного размера. Если взять готовые результаты и ещё раз убрать padding, появится новый сдвиг. Собственный ONNX-постпроцесс и готовый объект Results требуют разных точек подключения преобразования.

При работе с низкоуровневым выходом полезно сверить контракт со справкой scale_boxes и настройками LetterBox. Проверяйте цепочку на одном широком и одном высоком кадре. Возьмите известную рамку, перенесите её вперёд тем же преобразованием, которым готовится вход, затем восстановите. Такой тест покажет место ошибки независимо от качества детектора.

В платном курсе Computer Vision Engineer: от детекции и Doc-AI до продакшна вы пройдёте обучение детектора, разбор ошибок и постпроцесс, затем перенесёте модель в сервис. Для собственного инференса предусмотрен раздел ONNX, ONNX Runtime и OpenVINO. Откройте программу курса; начать работу с геометрией можно с пробного урока об IoU двух рамок.