После экспорта в ONNX модель ошибается: сравниваем RGB/BGR и входной тензор

Классификатор уверенно различал изображения в обучающем ноутбуке. После экспорта в ONNX и подключения к API уверенность упала, хотя размеры входа совпадают, а runtime не сообщает об ошибке. Первая полезная проверка здесь — сравнить числа, которые попадают в модель. Успешный запуск вычислений ещё не означает, что два сервиса одинаково поняли исходную картинку.

Изображение проходит декодер, изменение размера, перестановку каналов, перевод в вещественные числа и нормализацию. Часть этих операций могла войти в экспортированный граф, а часть остаться в коде приложения. Если приложение повторит встроенную нормализацию или передаст BGR вместо RGB, форма тензора останется допустимой.

Договоритесь, что именно находится на входе

Для конкретного примера возьмём классификатор с контрактом RGB, значения пикселей от 0 до 1, затем вычитание среднего и деление на стандартное отклонение по каналам. Итоговый порядок осей — NCHW: размер батча, каналы, высота и ширина. Такой набор параметров распространён у классификаторов; для вашего детектора или сегментатора нужно взять его собственные значения из обучения.

OpenCV при обычном цветном чтении возвращает BGR. Если библиотека загрузки уже дала RGB, повторная перестановка каналов испортит изображение. Поэтому проверку удобно начать с крошечного искусственного кадра: один пиксель ярко-красный, второй содержит синий и зелёный. На сером изображении перепутанные каналы обнаружить гораздо труднее.

import numpy as np

# Два пикселя, полученные декодером в порядке BGR.
bgr = np.array([[[0, 0, 255], [255, 128, 0]]], dtype=np.uint8)
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)

def prepare(image, swap_rb=True):
    rgb = image[..., ::-1] if swap_rb else image
    scaled = rgb.astype(np.float32) / 255.0
    normalized = (scaled - mean) / std
    return np.ascontiguousarray(normalized.transpose(2, 0, 1)[None])

# Эталон независимо строится из известных RGB-значений.
rgb_reference = np.array([[[255, 0, 0], [0, 128, 255]]], dtype=np.float32)
reference = ((rgb_reference / 255.0 - mean) / std).transpose(2, 0, 1)[None]
correct = prepare(bgr)
wrong_channels = prepare(bgr, swap_rb=False)
double_scaled = prepare(bgr) / 255.0
np.testing.assert_allclose(correct, reference, atol=1e-6)
assert correct.shape == wrong_channels.shape == (1, 3, 1, 2)
assert correct.dtype == np.float32 and correct.flags.c_contiguous
for name, candidate in [("RGB", correct), ("BGR", wrong_channels),
                        ("Повторное деление", double_scaled)]:
    error = np.max(np.abs(reference - candidate))
    print(name, "max_abs_error=", round(float(error), 4))
assert not np.allclose(reference, wrong_channels)
assert not np.allclose(reference, double_scaled)

Правильный вариант даст нулевую максимальную разницу с эталоном. Вариант без перестановки каналов — примерно 4.4444, а повторное деление — примерно 2.6296. При этом ошибочный BGR-тензор имеет точно такую же форму, как правильный: (1,3,1,2). Эталон построен из заранее заданных RGB-пикселей независимо от функции подготовки, поэтому проверка способна заметить пропущенную перестановку.

Где возникают разные результаты при одинаковой формуле

Порядок вычислений имеет значение. Формула (pixel/255-mean)/std использует среднее в диапазоне 0–1. Если сначала вычитать среднее из пикселей 0–255, его нужно перевести в другую шкалу. В blobFromImage OpenCV вычитание среднего выполняется перед умножением на scalefactor. Нельзя механически скопировать туда mean из другого интерфейса и ожидать тот же тензор.

Сначала добейтесь совпадения на искусственном кадре без resize. Затем сравните подготовленные тензоры на одном настоящем изображении. Если порядок каналов и числовой диапазон совпали, проверьте способ изменения размера: прямое растяжение, letterbox и центральный crop показывают модели разные области. Две реализации bilinear resize тоже могут расходиться из-за правил координат и округления, поэтому сравнивайте сохранённый результат подготовки, а не только названия операций.

После входа сравните выход до постобработки

Сохраните один эталонный входной массив .npy и передайте его исходной модели и ONNX-версии. В ONNX Runtime подача входов выполняется по имени через словарь в session.run; имя, shape и тип доступны у session.get_inputs(). Минимальный порядок вызова можно сверить в Python quickstart ONNX Runtime. Тип и форма входа не сообщают всю историю цветовых преобразований, поэтому отдельно храните контракт подготовки.

Если на одинаковом тензоре исходные выходы близки, ищите проблему после инференса: softmax, порядок классов, порог, NMS или восстановление координат. Если расходятся уже сырые выходы, разбирайте экспорт, режим вычислений и точность. Так каждая следующая проверка начинается на границе, которую предыдущая уже подтвердила.

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