Ultralytics YOLO27: Únete a la lista de espera.
Get Started
Ultralytics YOLO en producción·Elige un entorno de ejecución·Lección 2/9
Lecciónintermedio

Exportar a ONNX (y verificar la paridad)

Exporta un modelo ONNX portátil y comprueba que coincide con PyTorch en los cuadros delimitadores, las confianzas y mAP antes de implementar nada.

Una exportación que desplaza las confianzas en 0,1 no provoca un fallo. Cambia silenciosamente las detecciones que superan el umbral conf en cada dispositivo al que la implementas, y nadie se da cuenta hasta que los recuentos son incorrectos. ONNX es el punto de partida portátil para la implementación, y dedicar cinco minutos a comprobar la paridad es la forma más económica de protegerte en este curso.

Resultado

Exporta a ONNX, demuestra con cifras que coincide con PyTorch en los cuadros delimitadores, las confianzas y mAP, y explica cualquier diferencia que encuentres.

Vía rápida
Si ya tienes experiencia, aquí tienes un resumen.
  1. yolo export model=yolo26n.pt format=onnx dynamic=True.

  2. Procesa una imagen con ambos archivos y compara el número de cuadros delimitadores, las clases, las confianzas y las coordenadas, no solo el recuento.

  3. Ejecuta yolo val en ambos con el mismo batch; mAP50-95 debería diferir menos de 0,5 puntos.

  4. ¿Hay alguna diferencia? Comprueba la forma de entrada (estática o dinámica), tu propio preprocesamiento y qué cabezal (nms) has exportado.

Práctico

Exportar#

from ultralytics import YOLO

model = YOLO("yolo26n.pt")  # or runs/detect/pills_v1/weights/best.pt
model.export(format="onnx", dynamic=True)  # escribe yolo26n.onnx junto a los pesos

El registro muestra el opset elegido (opset 18 con onnx 1.23) y que onnxslim ha simplificado el grafo. Estas son las opciones que modifican el comportamiento, disponibles en el modo Export:

OpciónPredeterminadoCuándo cambiarlo
imgsz640La producción usa otro tamaño; la exportación estática queda fijada a ese tamaño
dynamicFalseEl tamaño del lote o de la imagen varía durante la inferencia
batch1Agrupas los datos durante la inferencia (tamaño máximo del lote en una exportación dinámica)
opsetla última versión compatibleUn entorno de ejecución antiguo rechaza el modelo; redúcelo
quantizeNone (FP32)16 para FP16; 8 para INT8 con calibración data
nmsNoneTrue integra NMS en el grafo; False selecciona el cabezal de YOLO26 sin NMS

Con el valor predeterminado de nms=None, la exportación devuelve predicciones sin procesar y el wrapper de Ultralytics ejecuta NMS, igual que PyTorch.

Comprueba la paridad con una imagen conocida#

Procesa la misma imagen con ambos modelos y compáralo todo:

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")

Ordenar ambos conjuntos por confianza empareja los mismos objetos, así que las dos últimas líneas comparan cada detección con su equivalente. Ahora ejecútalo dos veces: una como se muestra y otra sin dynamic=True:

ExportarCuadros delimitadoresMismas clasesDiferencia máxima de confianzaDiferencia máxima en los cuadros delimitadores
dynamic=True5 frente a 5sí0.00000.00 px
estático (predeterminado)5 frente a 5sí0.097710.11 px

La exportación estática no está rota. PyTorch adapta la imagen vertical bus.jpg al menor tamaño alineado con el stride, 640×480 (alto × ancho), mientras que un grafo estático de 640×640 tiene que rellenarla hasta formar un cuadrado con franjas grises. El relleno distinto produce predicciones ligeramente diferentes. Para comparar en igualdad de condiciones, exporta con dynamic=True o ejecuta PyTorch con rect=False para que rellene hasta formar el cuadrado completo, como hace la exportación estática (consulta forma fija frente a rectángulo mínimo); con rect=False, la exportación estática también coincide con una diferencia de 0,0000.

Valida ambos archivos#

Una imagen detecta errores graves; la validación detecta los más sutiles. Usa el mismo batch en ambos para que el preprocesamiento coincida:

yolo val model=yolo26n.pt data=coco8.yaml batch=1
yolo val model=yolo26n.onnx data=coco8.yaml batch=1

Ambos muestran mAP50-95 0.654 aquí. COCO8 demuestra que la canalización funciona, no que el modelo sea correcto; en tu propio conjunto de validación, investiga cualquier diferencia superior a 0,5 puntos. Comprobación completa:

Llama directamente a ONNX Runtime#

A menudo, los servicios de producción omiten el wrapper de Ultralytics y llaman directamente al motor de inferencia. En ese caso, el preprocesamiento es responsabilidad tuya y debe coincidir exactamente con el de Ultralytics. Con nms=False, la exportación de YOLO26 devuelve las detecciones finales, así que no hace falta código 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]

Un autobús (clase 5) y cuatro personas (clase 0), lo que coincide al píxel con YOLO(path)(img). La puntuación del autobús es 0,93 aquí y 0,88 con la exportación predeterminada porque nms=False selecciona otro cabezal; compara archivos exportados con el mismo valor de nms.

Errores habituales#

Comparar una exportación estática con la inferencia rectangular de PyTorch

Síntoma: las cajas son iguales, pero las confianzas difieren hasta en 0.1, sobre todo con imágenes no cuadradas. Solución: comprueba la paridad con dynamic=True o con rect=False en PyTorch, o evalúa la exportación estática según el mAP de validación.

Preprocesamiento manual distinto al de Ultralytics

Síntoma: tu propio código de ONNX Runtime encuentra menos cajas o las desplaza respecto a YOLO("model.onnx"). Solución: aplica letterbox (no estires la imagen), rellena con 114, convierte de BGR a RGB, divide entre 255 y deshaz el relleno y el escalado de las cajas, exactamente como hace ort_raw.py.

Mezclar cabezas

Síntoma: todas las confianzas son ligeramente distintas, incluso con formas coincidentes. Solución: compara una exportación nms=False (sin NMS) únicamente con otra exportación nms=False. Con nms=True, comprueba también que conf y iou, integrados en el grafo, coincidan con los que usas en otros sitios.

Pruébalo

Exporta tu modelo (o yolo26n.pt) a ONNX dos veces: una de forma estática y otra con dynamic=True; ejecuta parity.py con cada una. Después, ejecuta yolo val con .pt y la exportación dinámica .onnx, usando el mismo batch, y redacta un informe de paridad de tres líneas: la diferencia máxima de confianza de cada exportación y la diferencia en mAP50-95.

Cuándo se considera terminado
Has terminado la lección cuando se cumplen todas estas condiciones.
  • Tienes un archivo .onnx junto a tu .pt y una salida de paridad tanto para la exportación estática como para la dinámica.

  • Puedes explicar la diferencia de confianza de la exportación estática y mostrar la solución dynamic=True o rect=False que la elimina.

  • El mAP50-95 de validación del archivo ONNX está a menos de 0,5 puntos del modelo PyTorch, o se explica la diferencia.

Mostrar solución
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

Un informe de paridad para yolo26n en bus.jpg indica: diferencia máxima de confianza de la exportación estática de 0,0977 (forma letterbox, eliminada por rect=False), exportación dinámica de 0,0000, diferencia de mAP50-95 de 0,00 puntos. Publica la exportación dinámica.

Qué viene después

En hardware NVIDIA, el siguiente paso es TensorRT, donde FP16 e INT8 sacrifican un poco de precisión a cambio de mucha velocidad.