Экспорт в ONNX (и проверка соответствия)
Экспортируй переносимую модель ONNX, а затем проверь, что она совпадает с PyTorch по ограничивающим рамкам, уверенности и mAP, прежде чем что-либо выпускать.
Экспорт, который сдвигает значения уверенности на 0.1, не приводит к сбою. Он незаметно меняет результаты детекции, пересекающие порог conf, на каждом устройстве, куда ты отправляешь модель, и никто этого не замечает, пока подсчёты не начинают расходиться. ONNX — отправная точка для переносимого развёртывания, а пять минут на проверку соответствия — самая недорогая страховка в этом курсе.
Практические занятия
Экспорт#
from ultralytics import YOLO
model = YOLO("yolo26n.pt") # or runs/detect/pills_v1/weights/best.pt
model.export(format="onnx", dynamic=True) # записывает yolo26n.onnx рядом с весамиВ журнале показан выбранный opset (opset 18 с onnx 1.23) и указано, что onnxslim упростил граф. Параметры, меняющие поведение, описаны в режиме Export:
| Параметр | По умолчанию | Когда менять |
|---|---|---|
imgsz | 640 | В рабочей среде используется другой размер; статический экспорт фиксирует заданный размер |
dynamic | False | Размер пакета или изображения меняется при инференсе |
batch | 1 | При инференсе используется пакетная обработка (максимальный размер пакета для динамического экспорта) |
opset | последняя поддерживаемая версия | Более старая среда выполнения не принимает модель; понизь версию |
quantize | None (FP32) | 16 для FP16; 8 для INT8 с калибровкой data |
nms | None | True встраивает NMS в граф; False выбирает голову YOLO26 без NMS |
При значении nms=None по умолчанию экспорт возвращает исходные предсказания, а обёртка Ultralytics выполняет NMS — точно так же, как в PyTorch.
Проверь соответствие на известном изображении#
Прогони одно и то же изображение через обе модели и сравни всё:
from ultralytics import YOLO
pt = YOLO("yolo26n.pt")
onnx = YOLO(pt.export(format="onnx", dynamic=True))
img = "https://ultralytics.com/images/bus.jpg"
a = pt(img, verbose=False)[0].boxes
b = onnx(img, verbose=False)[0].boxes
ia, ib = a.conf.argsort(descending=True), b.conf.argsort(descending=True)
print(f"boxes: PyTorch {len(a)} ONNX {len(b)}")
print(f"same classes: {a.cls[ia].tolist() == b.cls[ib].tolist()}")
print(f"max conf diff: {(a.conf[ia] - b.conf[ib]).abs().max():.4f}")
print(f"max xyxy diff: {(a.xyxy[ia] - b.xyxy[ib]).abs().max():.2f} px")Сортировка обоих наборов по уверенности сопоставляет одни и те же объекты, поэтому в двух последних строках каждая детекция сравнивается со своей парой. Теперь запусти проверку дважды: сначала как показано, затем убрав dynamic=True:
| Экспорт | Рамки | Классы совпадают | Макс. разница уверенности | Макс. разница координат рамок |
|---|---|---|---|---|
dynamic=True | 5 против 5 | да | 0.0000 | 0.00 px |
| статический (по умолчанию) | 5 против 5 | да | 0.0977 | 10.11 px |
Со статическим экспортом всё в порядке. PyTorch дополняет портретное изображение bus.jpg до наименьшей формы, выровненной по шагу, — 640×480 (высота × ширина), тогда как статический граф 640×640 должен дополнить его до квадрата серыми полосами. Из-за разного дополнения предсказания немного различаются. Для корректного сравнения либо экспортируй модель с dynamic=True, либо запусти PyTorch с rect=False, чтобы он дополнил изображение до полного квадрата, как при статическом экспорте (см. фиксированная форма и минимальный прямоугольник); при значении rect=False статический экспорт также совпадает с точностью до 0.0000.
Проверь оба файла на валидации#
Одно изображение позволяет выявить грубые ошибки, а валидация — тонкие. Используй для обоих файлов один и тот же batch, чтобы предварительная обработка совпадала:
yolo val model=yolo26n.pt data=coco8.yaml batch=1
yolo val model=yolo26n.onnx data=coco8.yaml batch=1В обоих случаях здесь указано mAP50-95 0.654. COCO8 подтверждает работу конвейера, а не модели; на собственном наборе для валидации проверь любое расхождение больше 0.5 пункта. Вся проверка целиком:
Вызывай ONNX Runtime напрямую#
В рабочих сервисах часто пропускают обёртку Ultralytics и напрямую вызывают движок инференса. Тогда предварительная обработка становится твоей задачей и должна точно соответствовать Ultralytics. При значении nms=False экспорт YOLO26 возвращает готовые детекции, поэтому код NMS не нужен:
import cv2
import numpy as np
import onnxruntime as ort
from ultralytics import YOLO
from ultralytics.utils.downloads import safe_download
path = YOLO("yolo26n.pt").export(format="onnx", nms=False) # NMS-free head: output (1, 300, 6)
session = ort.InferenceSession(path, providers=["CPUExecutionProvider"]) # put CUDAExecutionProvider first on GPU
name = session.get_inputs()[0].name
# Preprocess like Ultralytics: letterbox to 640x640 with gray (114) padding, BGR->RGB, 0-1, NCHW
im = cv2.imread(safe_download("https://ultralytics.com/images/bus.jpg"))
h, w = im.shape[:2]
r = 640 / max(h, w)
nh, nw = round(h * r), round(w * r)
top, left = (640 - nh) // 2, (640 - nw) // 2
canvas = np.full((640, 640, 3), 114, np.uint8)
canvas[top : top + nh, left : left + nw] = cv2.resize(im, (nw, nh), interpolation=cv2.INTER_LINEAR)
x = canvas[..., ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255
(out,) = session.run(None, {name: x})
det = out[0][out[0][:, 4] > 0.25] # rows: x1, y1, x2, y2, score, class
det[:, [0, 2]] = ((det[:, [0, 2]] - left) / r).clip(0, w) # undo padding and scale
det[:, [1, 3]] = ((det[:, [1, 3]] - top) / r).clip(0, h)
for x1, y1, x2, y2, score, cls in det:
print(f"class {int(cls):2d} score {score:.2f} box [{x1:.0f}, {y1:.0f}, {x2:.0f}, {y2:.0f}]")class 5 score 0.93 box [6, 229, 807, 749]
class 0 score 0.92 box [47, 399, 237, 902]
class 0 score 0.90 box [223, 405, 345, 863]
class 0 score 0.86 box [669, 391, 810, 880]
class 0 score 0.52 box [0, 554, 65, 874]Один автобус (класс 5) и четыре человека (класс 0); YOLO(path)(img) совпадает до пикселя. Здесь оценка автобуса равна 0.93, а при экспорте по умолчанию — 0.88, потому что nms=False выбирает другую голову; сравнивай файлы, экспортированные с одинаковым значением nms.
Подводные камни#
Симптом: рамки совпадают, но значения уверенности отличаются до 0.1; сильнее всего — на непрямоугольных изображениях. Решение: проверь соответствие с помощью dynamic=True, или с помощью rect=False на стороне PyTorch, либо оцени статический экспорт по mAP на валидации.
Симптом: собственный код ONNX Runtime находит меньше рамок или рамки смещены по сравнению с YOLO("model.onnx"). Решение: используй letterbox (не растягивай изображение), добавляй отступы значением 114, преобразуй BGR в RGB, дели значения на 255 и отменяй сдвиг и масштабирование рамок — точно так же, как это делает ort_raw.py.
Симптом: все значения уверенности немного отличаются, даже при совпадающих формах тензоров. Решение: сравнивай экспорт nms=False (без NMS) только с другим экспортом nms=False. Для nms=True также проверь, что conf и iou, встроенные в граф, совпадают с используемыми в других местах.
Показать решение
from ultralytics import YOLO
if __name__ == "__main__": # validation starts worker processes on macOS and Windows
weights = "yolo26n.pt" # or runs/detect/pills_v1/weights/best.pt
onnx_path = YOLO(weights).export(format="onnx", dynamic=True)
maps = {}
for w in (weights, onnx_path):
maps[w] = YOLO(w).val(data="coco8.yaml", batch=1, plots=False).box.map # or my_dataset/data.yaml
print(f"{w}: mAP50-95 = {maps[w]:.4f}")
print(f"gap = {abs(maps[weights] - maps[onnx_path]) * 100:.2f} points")yolo26n.pt: mAP50-95 = 0.6540
yolo26n.onnx: mAP50-95 = 0.6540
gap = 0.00 pointsВ отчёте о паритете для yolo26n на bus.jpg указано: максимальное расхождение confidence при статическом экспорте — 0.0977 (форма letterbox, устраняется с помощью rect=False), при динамическом экспорте — 0.0000, расхождение mAP50-95 — 0.00 пункта. Выпускай динамический экспорт.