Ultralytics YOLO27 : Inscris-toi sur la liste d’attente.
Get Started
Ultralytics YOLO en production·Choisir un moteur d'exécution·Leçon 2/9
Leçonintermédiaire

Exporter au format ONNX (et vérifier la parité)

Exporte un modèle ONNX portable, puis vérifie qu'il correspond à PyTorch pour les boîtes, les confiances et mAP avant tout déploiement.

Un export qui décale les confiances de 0.1 ne provoque pas de plantage. Il fait discrètement passer des détections de part et d'autre du seuil conf sur chaque appareil où tu le déploies, et personne ne s'en aperçoit avant que les comptages soient erronés. ONNX est le point de départ portable pour le déploiement, et cinq minutes de vérification de parité constituent l'assurance la moins coûteuse de ce cours.

Résultat

Exporte au format ONNX, démontre avec des chiffres que le résultat correspond à PyTorch pour les boîtes, les confiances et mAP, et explique tout écart constaté.

Parcours accéléré
Si tu connais déjà les bases, voici la version courte.
  1. yolo export model=yolo26n.pt format=onnx dynamic=True.

  2. Fais passer une image dans les deux fichiers ; compare le nombre de boîtes, les classes, les confiances et les coordonnées, pas seulement le nombre.

  3. Exécute yolo val sur les deux avec le même batch ; mAP50-95 doit se situer à moins de 0.5 point d'écart.

  4. Un écart ? Vérifie la forme d'entrée (statique ou dynamique), ton prétraitement personnalisé et la tête (nms) que tu as exportée.

Pratique

Exporter#

from ultralytics import YOLO

model = YOLO("yolo26n.pt")  # or runs/detect/pills_v1/weights/best.pt
model.export(format="onnx", dynamic=True)  # écrit yolo26n.onnx à côté des poids

Le journal indique l'opset choisi (opset 18 avec onnx 1.23) et confirme que onnxslim a simplifié le graphe. Voici les options qui modifient le comportement, issues du mode Export :

OptionPar défautÀ modifier quand
imgsz640La production utilise une autre taille ; un export statique est figé à cette taille
dynamicFalseLa taille du lot ou la forme des images varie pendant l'inférence
batch1Tu traites les lots pendant l'inférence (taille maximale du lot pour un export dynamique)
opsetdernière version prise en chargeUn environnement d'exécution plus ancien rejette le modèle ; réduis cette valeur
quantizeNone (FP32)16 pour FP16 ; 8 pour INT8 avec calibration data
nmsNoneTrue intègre NMS au graphe ; False sélectionne la tête sans NMS de YOLO26

Avec la valeur par défaut nms=None, l'export renvoie les prédictions brutes et le wrapper Ultralytics exécute NMS, exactement comme PyTorch.

Vérifier la parité sur une image connue#

Fais passer la même image dans les deux modèles et compare tout :

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

Le tri des deux ensembles par confiance associe les mêmes objets ; les deux dernières lignes comparent donc chaque détection à son équivalent. Exécute maintenant le script deux fois, une fois comme indiqué et une fois après avoir supprimé dynamic=True :

ExporterBoîtesClasses identiquesÉcart maximal de confianceÉcart maximal des boîtes
dynamic=True5 contre 5oui0.00000.00 px
statique (par défaut)5 contre 5oui0.097710.11 px

L'export statique n'est pas défectueux. PyTorch adapte l'image portrait bus.jpg à la plus petite forme alignée sur le stride, 640×480 (hauteur × largeur), tandis qu'un graphe statique 640×640 doit la compléter pour obtenir un carré avec des bandes grises. Ce padding différent produit des prédictions légèrement différentes. Pour comparer des résultats équivalents, exporte avec dynamic=True ou exécute PyTorch avec rect=False afin qu'il ajoute le padding jusqu'au carré complet, comme le fait l'export statique (voir forme fixe ou rectangle minimal) ; avec rect=False, l'export statique correspond aussi exactement, avec un écart de 0.0000.

Valider les deux fichiers#

Une image détecte les erreurs grossières ; la validation révèle les plus subtiles. Utilise le même batch pour les deux afin d'avoir un prétraitement identique :

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

Les deux affichent mAP50-95 0.654 ici. COCO8 vérifie le pipeline, pas le modèle ; sur ton propre jeu de validation, examine tout écart supérieur à 0.5 point. Voici la vérification complète :

Appeler directement ONNX Runtime#

Les services de production ignorent souvent le wrapper Ultralytics et appellent directement le moteur d'inférence. Le prétraitement est alors de ta responsabilité et doit correspondre exactement à celui d'Ultralytics. Avec nms=False, l'export YOLO26 renvoie les détections finales, aucun code NMS n'est donc nécessaire :

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 bus (classe 5) et quatre personnes (classe 0), correspondant au pixel près à YOLO(path)(img). Le score du bus est de 0.93 ici et de 0.88 avec l'export par défaut, car nms=False sélectionne une autre tête ; compare des fichiers exportés avec la même valeur nms.

Pièges#

Comparer un export statique à l'inférence PyTorch rectangulaire

Symptôme : mêmes boîtes, mais écarts de confiance pouvant atteindre 0.1, surtout sur les images non carrées. Solution : vérifie la parité avec dynamic=True, ou avec rect=False côté PyTorch, ou évalue l'export statique d'après la mAP de validation.

Prétraitement écrit manuellement qui diffère de celui d'Ultralytics

Symptôme : ton propre code ONNX Runtime détecte moins de boîtes, ou des boîtes décalées, par rapport à YOLO("model.onnx"). Solution : applique un letterbox (ne déforme pas l'image), ajoute un remplissage de 114, convertis BGR en RGB, divise par 255 et annule le remplissage et le redimensionnement des boîtes, exactement comme le fait ort_raw.py.

Mélanger les têtes

Symptôme : toutes les confiances diffèrent légèrement, même lorsque les formes correspondent. Solution : compare un export nms=False (sans NMS) uniquement à un autre export nms=False. Avec nms=True, vérifie aussi que conf et iou intégrés au graphe correspondent à ceux utilisés ailleurs.

Essayer

Exporte ton modèle (ou yolo26n.pt) au format ONNX deux fois, en statique et avec dynamic=True, puis exécute parity.py sur chaque export. Exécute ensuite yolo val sur le .pt et l'.onnx dynamique avec le même batch, puis rédige un rapport de parité en trois lignes : l'écart maximal de confiance pour chaque export et l'écart mAP50-95.

Terminé lorsque
Tu as terminé la leçon lorsque tous les éléments suivants sont réunis.
  • Tu as un fichier .onnx à côté de ton .pt et un relevé de parité pour les exports statique et dynamique.

  • Tu peux expliquer l'écart de confiance de l'export statique et montrer la correction dynamic=True ou rect=False qui le supprime.

  • Le mAP50-95 de validation sur le fichier ONNX est à moins de 0,5 point de celui du modèle PyTorch, ou l’écart est expliqué.

Afficher la solution
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 rapport de parité pour yolo26n sur bus.jpg indique : écart maximal de confiance de 0,0977 pour l’export statique (forme letterbox, supprimé par rect=False), 0,0000 pour l’export dynamique, écart de mAP50-95 de 0,00 point. Déploie l’export dynamique.

Et ensuite ?

Sur du matériel NVIDIA, l’étape suivante est TensorRT, où FP16 et INT8 sacrifient un peu de précision pour gagner beaucoup en vitesse.