Esportare in ONNX (e verificare la parità)
Esporta un modello ONNX portabile, poi dimostra che corrisponde a PyTorch per bounding box, confidence e mAP prima di distribuire qualsiasi cosa.
Un'esportazione che sposta le confidence di 0,1 non genera errori. Sposta silenziosamente le rilevazioni oltre la soglia conf su ogni dispositivo su cui distribuisci il modello, e nessuno se ne accorge finché i conteggi non risultano errati. ONNX è il punto di partenza portabile per la distribuzione e cinque minuti di verifica della parità sono la forma di garanzia meno costosa di questo corso.
Pratico
Esportazione#
from ultralytics import YOLO
model = YOLO("yolo26n.pt") # oppure runs/detect/pills_v1/weights/best.pt
model.export(format="onnx", dynamic=True) # scrive yolo26n.onnx accanto ai pesiIl log mostra l'opset scelto (opset 18 con onnx 1.23) e conferma che onnxslim ha semplificato il grafo. Ecco le opzioni che modificano il comportamento, tratte dalla modalità Export:
| Opzione | Predefinita | Modificalo quando |
|---|---|---|
imgsz | 640 | In produzione usi un'altra dimensione; un'esportazione statica è vincolata a quella dimensione |
dynamic | False | La dimensione del batch o dell'immagine varia durante l'inferenza |
batch | 1 | Usi il batching durante l'inferenza (batch massimo per un'esportazione dinamica) |
opset | ultima versione supportata | Un runtime meno recente rifiuta il modello; riduci questo valore |
quantize | None (FP32) | 16 per FP16; 8 per INT8 con calibrazione data |
nms | None | True incorpora NMS nel grafo; False seleziona la head senza NMS di YOLO26 |
Con il valore predefinito nms=None, l'esportazione restituisce le predizioni grezze e il wrapper Ultralytics esegue NMS, esattamente come PyTorch.
Verifica la parità su un'immagine nota#
Esegui la stessa immagine con entrambi i modelli e confronta ogni aspetto:
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")Ordinando entrambi gli insiemi per confidence, abbini gli stessi oggetti; le ultime due righe confrontano quindi ogni rilevazione con la corrispondente. Ora esegui il test due volte, una come mostrato e una rimuovendo dynamic=True:
| Esportazione | Bounding box | Stesse classi | Differenza max. di confidence | Differenza max. delle bounding box |
|---|---|---|---|---|
dynamic=True | 5 vs 5 | sì | 0.0000 | 0.00 px |
| statica (predefinita) | 5 vs 5 | sì | 0.0977 | 10.11 px |
L'esportazione statica non è difettosa. PyTorch adatta l'immagine verticale bus.jpg alla forma minima allineata allo stride, 640×480 (altezza × larghezza), mentre un grafo statico 640×640 deve aggiungere padding grigio fino a ottenere un quadrato. Il padding diverso produce predizioni leggermente diverse. Per confrontare condizioni equivalenti, esporta con dynamic=True oppure esegui la parte PyTorch con rect=False, in modo che aggiunga padding fino al quadrato completo come fa l'esportazione statica (vedi forma fissa e rettangolo minimo); con rect=False, anche l'esportazione statica corrisponde con una differenza di 0.0000.
Convalida entrambi i file#
Una sola immagine permette di rilevare gli errori più evidenti; la convalida individua quelli più sottili. Usa lo stesso batch per entrambi, così il preprocessing corrisponde:
yolo val model=yolo26n.pt data=coco8.yaml batch=1
yolo val model=yolo26n.onnx data=coco8.yaml batch=1Entrambi riportano mAP50-95 0.654 in questo caso. COCO8 verifica la pipeline, non il modello; sul tuo set di convalida, esamina qualsiasi differenza superiore a 0,5 punti. Ecco la verifica completa:
Chiama direttamente ONNX Runtime#
Spesso i servizi in produzione saltano il wrapper Ultralytics e chiamano direttamente il motore di inferenza. In tal caso, il preprocessing è responsabilità tua e deve corrispondere esattamente a quello di Ultralytics. Con nms=False, l'esportazione YOLO26 restituisce le rilevazioni finali, quindi non serve codice 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 autobus (classe 5) e quattro persone (classe 0), con YOLO(path)(img) corrispondente al pixel. In questo caso l'autobus ha una confidence di 0,93, mentre con l'esportazione predefinita è 0,88, perché nms=False seleziona una head diversa; confronta file esportati con lo stesso valore di nms.
Insidie#
Sintomo: stessi riquadri, confidenze diverse fino a 0.1, soprattutto sulle immagini non quadrate. Soluzione: verifica la parità con dynamic=True o con rect=False sul lato PyTorch, oppure valuta l'esportazione statica in base alla mAP di validazione.
Sintomo: il tuo codice ONNX Runtime trova meno riquadri, o riquadri spostati, rispetto a YOLO("model.onnx"). Soluzione: usa il letterbox (non deformare l'immagine), applica un padding con 114, converti da BGR a RGB, dividi per 255 e annulla il padding e il ridimensionamento dei riquadri, esattamente come fa ort_raw.py.
Sintomo: tutte le confidenze sono leggermente diverse, anche con forme corrispondenti. Soluzione: confronta un'esportazione nms=False (senza NMS) solo con un'altra esportazione nms=False. Con nms=True, verifica anche che conf e iou incorporati nel grafo corrispondano a quelli usati altrove.
Mostra la soluzione
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 report di parità per yolo26n su bus.jpg riporta: differenza massima di confidenza dell'esportazione statica pari a 0.0977 (forma letterbox, eliminata da rect=False), esportazione dinamica 0.0000, differenza mAP50-95 di 0.00 punti. Distribuisci l'esportazione dinamica.