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.
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 poidsLe 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 :
| Option | Par défaut | À modifier quand |
|---|---|---|
imgsz | 640 | La production utilise une autre taille ; un export statique est figé à cette taille |
dynamic | False | La taille du lot ou la forme des images varie pendant l'inférence |
batch | 1 | Tu traites les lots pendant l'inférence (taille maximale du lot pour un export dynamique) |
opset | dernière version prise en charge | Un environnement d'exécution plus ancien rejette le modèle ; réduis cette valeur |
quantize | None (FP32) | 16 pour FP16 ; 8 pour INT8 avec calibration data |
nms | None | True 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 :
| Exporter | Boîtes | Classes identiques | Écart maximal de confiance | Écart maximal des boîtes |
|---|---|---|---|---|
dynamic=True | 5 contre 5 | oui | 0.0000 | 0.00 px |
| statique (par défaut) | 5 contre 5 | oui | 0.0977 | 10.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=1Les 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#
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.
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.
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.
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 pointsUn 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.