OpenCV 5's new DNN engine and Ultralytics YOLO26 were made for each other: YOLO26 is end-to-end (no NMS), and the 5.x engine finally covers the ONNX operators modern detectors emit. This tutorial exports YOLO26 to ONNX, runs it with cv2.dnn on OpenCV 5.0, and shows how the backend choice interacts with the engine choice.
You will need opencv-python>=5.0 and ultralytics.
1. Export YOLO26 to ONNX
YOLO26 ships in five detection scales (yolo26n through yolo26x) plus seg, pose, OBB, classification and depth variants. The default export is the NMS-free end-to-end head:
from ultralytics import YOLO
model = YOLO("yolo26n.pt")
model.export(format="onnx", imgsz=640, opset=17, simplify=True, dynamic=False)
# -> yolo26n.onnx
Inspect the graph to confirm the output shape before writing any post-processing:
import onnx
m = onnx.load("yolo26n.onnx")
for o in m.graph.output:
print(o.name, [d.dim_value for d in o.type.tensor_type.shape.dim])
# output0 [1, 300, 6] -> rows of x1, y1, x2, y2, score, class_id
That [1, N, 6] shape is the end-to-end format: each row is already a final detection in input-pixel coordinates, sorted by score. There is no 8400-anchor tensor and no NMS step to write.
2. Load it in OpenCV 5
import cv2
import numpy as np
net = cv2.dnn.readNetFromONNX("yolo26n.onnx") # ENGINE_AUTO: new engine first
print(cv2.__version__) # 5.0.x
readNetFromONNX defaults to ENGINE_AUTO, which tries the new graph engine and falls back to the classic one if the model fails to load. To be explicit (and to fail loudly if the new engine cannot take the model):
net = cv2.dnn.readNetFromONNX("yolo26n.onnx", engine=cv2.dnn.ENGINE_NEW)
3. Pre-processing: letterbox, then blob
YOLO26 expects a 640x640 RGB input in [0, 1]. Letterboxing preserves aspect ratio, and you need the scale and padding later to map boxes back:
def letterbox(img, size=640, color=(114, 114, 114)):
h, w = img.shape[:2]
r = min(size / h, size / w)
nh, nw = int(round(h * r)), int(round(w * r))
resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR)
top = (size - nh) // 2
left = (size - nw) // 2
canvas = np.full((size, size, 3), color, dtype=np.uint8)
canvas[top:top + nh, left:left + nw] = resized
return canvas, r, (left, top)
img = cv2.imread("bus.jpg")
lb, r, (dx, dy) = letterbox(img)
blob = cv2.dnn.blobFromImage(lb, scalefactor=1 / 255.0, size=(640, 640), swapRB=True, crop=False)
net.setInput(blob)
out = net.forward() # shape (1, 300, 6)
4. Post-processing (there is almost none)
def decode(out, r, dx, dy, conf=0.25):
dets = out[0] # (300, 6)
dets = dets[dets[:, 4] >= conf]
boxes = dets[:, :4].copy()
boxes[:, [0, 2]] = (boxes[:, [0, 2]] - dx) / r # undo letterbox
boxes[:, [1, 3]] = (boxes[:, [1, 3]] - dy) / r
return boxes, dets[:, 4], dets[:, 5].astype(int)
boxes, scores, classes = decode(out, r, dx, dy)
for (x1, y1, x2, y2), s, c in zip(boxes, scores, classes):
cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2)
cv2.putText(img, f"{c}:{s:.2f}", (int(x1), int(y1) - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
cv2.imwrite("out.jpg", img)
Compare this with a YOLOv8-era pipeline: no transposing an (84, 8400) tensor, no cv2.dnn.NMSBoxes. If you exported with nms=False or an older Ultralytics model, you get the anchor tensor back and need the classic decode; check the output shape from step 1 rather than assuming.
5. Backends: CPU vs CUDA vs OpenVINO
This is where the engine choice matters. In OpenCV 5.0 the new engine is CPU-only. If you set a non-CPU backend and target, you must load with the classic engine:
# CPU, new engine (default) - fastest single-dependency CPU path
net_cpu = cv2.dnn.readNetFromONNX("yolo26n.onnx")
# CUDA - requires an OpenCV build with WITH_CUDA=ON and the classic engine
net_cuda = cv2.dnn.readNetFromONNX("yolo26n.onnx", engine=cv2.dnn.ENGINE_CLASSIC)
net_cuda.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA)
net_cuda.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA_FP16)
# OpenVINO - requires a build with WITH_OPENVINO=ON and the classic engine
net_ov = cv2.dnn.readNetFromONNX("yolo26n.onnx", engine=cv2.dnn.ENGINE_CLASSIC)
net_ov.setPreferableBackend(cv2.dnn.DNN_BACKEND_INFERENCE_ENGINE)
net_ov.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
If your build has the bundled ONNX Runtime (-DWITH_ONNXRUNTIME=ON, optionally -DDOWNLOAD_ONNXRUNTIME_GPU=ON), engine=cv2.dnn.ENGINE_ORT gives you ORT's execution providers through the same Net API. The environment variable OPENCV_FORCE_DNN_ENGINE (1 classic, 2 new, 3 auto, 4 ORT) lets you switch without code changes, which is the easiest way to A/B engines in a deployed binary.
Note that the classic engine on CUDA may reject some operators that the new engine accepts; if the CUDA load fails on the end-to-end graph, export with nms=False and add cv2.dnn.NMSBoxes back, or go through TensorRT directly (see our Jetson Orin Nano tutorial).
6. Benchmarking
Measure, do not guess. Warm up, then time the forward pass only:
import time
def bench(net, blob, n=100):
net.setInput(blob)
for _ in range(10):
net.forward()
t0 = time.perf_counter()
for _ in range(n):
net.forward()
return (time.perf_counter() - t0) / n * 1000
for name, n in [("cpu-new", net_cpu), ("cuda-classic", net_cuda)]:
print(f"{name}: {bench(n, blob):.2f} ms/frame")
The OpenCV team's own CPU benchmarks show the new engine beating ONNX Runtime on YOLOv8n (10.9 ms vs 12.15 ms on an i9-14900KS), and YOLO26n is designed to be faster on CPU than its predecessors, so a well-built CPU-only deployment is no longer a compromise for single-stream detection. Your numbers will depend on your CPU, your build flags (check cv2.getBuildInformation() for AVX2/AVX-512 and the IPP/KleidiCV HALs) and input size, so record them per target.
7. Sanity-check against Ultralytics
Before shipping, confirm the OpenCV path agrees with the reference implementation on the same image:
ref = YOLO("yolo26n.pt")("bus.jpg", imgsz=640, conf=0.25)[0]
print(len(ref.boxes), "reference detections vs", len(boxes), "OpenCV detections")
Small score differences are normal (different resize kernels; remember OpenCV 5 changed INTER_NEAREST behavior); a different count of confident detections is not, and usually means a letterbox or swapRB mistake.
Running detection in production and want it tuned for your hardware? Talk to us.