Export Guide¶
Install the export extras¶
Exporting is not part of the base install. Pull in the runtime you are targeting:
pip install "modern-yolonas[onnx]" # ONNX export and onnxruntime (CPU)
pip install "modern-yolonas[onnx-gpu]" # the same, against onnxruntime's CUDA build
pip install "modern-yolonas[openvino]" # OpenVINO IR export, with NNCF for INT8
pip install "modern-yolonas[tensorrt]" # TensorRT engine building
onnx and onnx-gpu are mutually exclusive and declared as conflicting extras.
Both install a module named onnxruntime, and with both present you get whichever
wheel landed last — usually the CPU one, which drops CUDAExecutionProvider from
the provider list without warning. modern_yolonas.export.onnx_session asserts on
session.get_providers() for the same reason: the provider a build supports and
the provider a session resolved to are different questions, and only the second
one is the one you are about to benchmark.
Picking an input size¶
The spatial dimensions are baked into the graph — the detection head builds its anchor grid from them — so one file serves one resolution. At 640 the model produces 8400 anchors; at 320, 2100.
The batch dimension stays symbolic for plain ONNX and is fixed at 1 everywhere else:
TensorRT would need an optimisation profile for a symbolic batch, OpenVINO's INT8
calibration is simpler without one, and the NMS surgery indexes the batch dimension.
--static-batch fixes it for ONNX too.
ONNX export¶
The result is one self-contained file. PyTorch's exporter defaults to putting the
weights in a sibling model.onnx.data; this turns that off, because an .onnx
shipped without its sidecar loads and then fails at the first inference, and
TensorRT's byte-string parser cannot resolve the sidecar at all.
import torch
from modern_yolonas import yolo_nas_s
model = yolo_nas_s(pretrained=True).eval()
# Fuse RepVGG branches for inference
for m in model.modules():
if hasattr(m, "fuse_block_residual_branches"):
m.fuse_block_residual_branches()
dummy = torch.randn(1, 3, 640, 640)
torch.onnx.export(
model, dummy, "yolo_nas_s.onnx",
input_names=["images"],
output_names=["pred_bboxes", "pred_scores"],
dynamic_axes={"images": {0: "batch"}},
opset_version=18,
)
Validate with ONNX Runtime¶
import onnxruntime as ort
import numpy as np
session = ort.InferenceSession("yolo_nas_s.onnx")
inputs = {"images": np.random.randn(1, 3, 640, 640).astype(np.float32)}
bboxes, scores = session.run(None, inputs)
print(f"bboxes: {bboxes.shape}, scores: {scores.shape}")
Embedding export¶
Two targets export the feature embeddings rather than (or alongside) the detections.
# Embeddings only — the detection head is not in the graph
yolonas export --model yolo_nas_s --target embedding --output embedding.onnx
# Detections and an embedding, from one pass
yolonas export --model yolo_nas_s --target combined --output combined.onnx
# Pick the layers and pooling, exactly as FeaturePooler does
yolonas export --model yolo_nas_s --target embedding --embed-layers c4,c5 --embed-pooling max
yolonas export --model yolo_nas_s --target embedding --no-normalize
| Target | Inputs | Outputs |
|---|---|---|
embedding |
images, valid_region |
embedding |
combined |
images, valid_region |
pred_bboxes, pred_scores, embedding |
combined is the deployment form of
predict(..., Task.DETECT | Task.EMBED): the
backbone and neck run once and both results are read off them, rather than shipping two
models over the same frames. pred_bboxes are in canvas coordinates with NMS not yet
applied — the same contract as the plain detection export, so existing postprocessing
applies unchanged.
The valid_region input¶
Both graphs take a second input: valid_region, [B, 4] int64
(left, top, right, bottom) in canvas pixels, saying where each image's real pixels sit
inside the letterbox.
It is a separate input rather than a constant because it depends on the aspect ratio of the image being embedded, which is not known at export time. And it is not optional: pooling the gray letterbox padding in makes embeddings cluster by aspect ratio instead of by content — measured at 0.958 cosine between an unrelated noise image and a street photo, against 0.396 when the padding is excluded.
valid_region is a public function that takes what preprocess already returns:
import numpy as np
import onnxruntime as ort
from modern_yolonas.inference.embed import valid_region
from modern_yolonas.inference.preprocess import preprocess
session = ort.InferenceSession("embedding.onnx")
tensor, scale, pad = preprocess(image, 640) # image is a BGR array
region = valid_region(image, scale, pad) # (left, top, right, bottom)
embedding = session.run(None, {
"images": tensor.numpy(),
"valid_region": np.array([region], dtype=np.int64),
})[0] # (1, 768), L2-normalized
To pool the whole canvas anyway, pass (0, 0, canvas, canvas) — but that is the
behaviour the input exists to avoid.
examples/embed_onnx.py is this loop over a folder, ranking a gallery against a query.
Per-object embeddings — the objects target¶
yolonas export --model yolo_nas_s --target objects --output objects.onnx \
--conf-threshold 0.25 --iou-threshold 0.45 --max-detections 20
A self-contained graph: image in, detections and one vector per detection out, with NMS and ROI pooling inside it.
| Output | Shape | Contents |
|---|---|---|
detections |
[D, 7] |
batch, x1, y1, x2, y2, confidence, class_id |
object_embedding |
[D, E] |
one row per detection, same order |
embedding |
[B, E] |
the whole-image vector, as in the other targets |
Row i of object_embedding describes row i of detections. That is the contract, and
it is what the tests pin.
This one could not be traced out of PyTorch. Which boxes exist depends on which survive
NMS — a data-dependent shape torch.export will not produce — so the graph is built by
exporting a base model whose feature maps are outputs, then adding NonMaxSuppression,
RoiAlign and the pooling as ONNX nodes. Same approach as the frigate target.
detections, object_embedding, embedding = session.run(None, {
"images": tensor.numpy(),
"valid_region": np.array([region], dtype=np.int64),
})
people = detections[detections[:, 6] == COCOClass.PERSON]
Boxes come back in canvas coordinates, like the plain detection export.
rescale_boxes(boxes, scale, pad, image.shape[:2]) maps them to the source image — the
same function the PyTorch path uses.
Boxes are clipped to valid_region before they are embedded, matching
embed_boxes and predict(..., Task.EMBED_OBJECTS): outside it there is only letterbox
padding, so a detection running off the edge is described by the part of it that is real.
A box that clips to zero area samples nothing and comes back as a zero vector, never NaN.
Thresholds are not the same as postprocess's¶
--max-detections is max_output_boxes_per_class — the ONNX NMS operator counts per
class, where postprocess caps the total per image. The defaults (--iou-threshold
0.45, --max-detections 20) are the Frigate-tuned ones this flag has always had. To get
closer to what predict does, pass --iou-threshold 0.7 --max-detections 300.
Exact detection parity with postprocess is not claimed: the graph uses the ONNX NMS
operator and postprocess uses torchvision.ops.batched_nms with a top-1024 prefilter,
so the surviving sets can differ at the margin. What is pinned is that for whatever
boxes the graph does emit, the vectors match PyTorch ROI pooling on those same boxes.
OpenVINO export¶
yolonas export --model yolo_nas_s --format openvino --output model.xml
yolonas export --model yolo_nas_s --format openvino --precision fp16
yolonas export --model yolo_nas_s --format openvino --precision int8 \
--calibration-dir ~/datasets/coco/images/val2017
INT8 is the reason to reach for OpenVINO on a CPU or an integrated GPU, and it is where the speedup is — see the runtime matrix. Calibrate on representative images, not on noise: quantizing against random input puts the activation ranges somewhere the model never goes.
The head's decode tail — DFL softmax, the anchor arithmetic, the stack back into boxes — is excluded from quantization. It is elementwise work on a few thousand values, so INT8 buys nothing there, and OpenVINO's low-precision transformations throw on the dequantization it produces. The convolutions, which is where the compute is, are quantized normally.
TensorRT export¶
Three things about TensorRT 11 that shape this command:
- It is always strongly typed.
BuilderFlag.FP16andBuilderFlag.INT8no longer exist, so the engine's precision is whatever the ONNX graph's is.--precision fp16therefore exports an FP16 ONNX first and builds from that. - INT8 needs a QDQ graph. The implicit calibrator API is gone. Produce a graph
carrying QuantizeLinear/DequantizeLinear nodes with
yolonas quantize, and TensorRT will honour them. - An engine is not portable. It is compiled for one GPU architecture, one
TensorRT version and one shape profile, and refuses to deserialise anywhere else.
A
.engine.jsonis written beside it recording exactly what it was built against.
--hardware-compatible builds with HardwareCompatibilityLevel.AMPERE_PLUS, so the
engine loads on any Ampere-or-newer GPU rather than on one card. That is what makes
publishing a prebuilt engine possible at all, and it is not free: measured on an
RTX 3060 Laptop with YOLO-NAS-S, it costs 16% at 320 and 8% at 640, and no accuracy
(47.31 AP against 47.33 for a natively built FP32 engine). --version-compatible
is separate and lets the engine load under a later TensorRT release; it has its own
cost, which is why the two are separate flags rather than one.
Running an engine:
from modern_yolonas.export import EngineRunner
runner = EngineRunner("yolo_nas_s_320_fp16.engine")
boxes, scores = runner(images) # images: CUDA tensor [1, 3, 320, 320]
EngineRunner binds torch's own CUDA tensors into TensorRT, which avoids a second
allocator and a second CUDA context. It runs on a stream of its own — enqueueing on
the default stream makes TensorRT insert extra synchronizations — and orders that
stream against torch's before and after the call. Skipping that ordering is not a
performance detail: the caching allocator associates a tensor with the stream that
produced it, so TensorRT can read an input whose copy has not landed. It does not
fail. It returns plausible numbers that cost 16 AP, which is how this was found.
An engine's accuracy is worth checking rather than assuming:
uv run examples/runtime_accuracy.py --coco ~/datasets/coco --input-size 640 \
--tensorrt yolo_nas_s_640_fp16.engine
NMS inside the graph¶
--target end2end appends NonMaxSuppression to the graph, so the model returns
detections [D, 7] — [batch, x1, y1, x2, y2, confidence, class_id] — instead of raw
[N, 4] + [N, 80] tensors.
yolonas export --model yolo_nas_s --format onnx --target end2end \
--input-size 320 --conf-threshold 0.25 --iou-threshold 0.45
This does not make inference faster, and the measurement says so. On an RTX 3060
Laptop, YOLO-NAS-S at 320 in TensorRT FP16: 1.17 ms for the model alone, 1.55 ms for
the model plus torchvision's batched_nms, and 1.92 ms with NMS in the graph. ONNX's
NonMaxSuppression is simply slower than torchvision's. What end2end buys is
deployment shape: one file, one call, no Python in the inference path, and no
megabyte of raw tensors crossing back per frame in a pipeline that does copy them.
Pre-processing stays outside the graph deliberately. The letterbox scale and padding are per-image and the caller needs them anyway to map boxes back to original pixels, so baking them in would hide numbers the caller has to have. The Frigate target is the one exception, because Frigate does the resize itself.
INT8 and end2end cannot be combined: NNCF cannot calibrate through a baked-in
NonMaxSuppression.
Frigate integration¶
The --target frigate option bakes preprocessing (uint8 BGR input) and NMS
into the ONNX graph, producing a single self-contained model:
Output tensor shape: [D, 7] with columns [batch, x1, y1, x2, y2, confidence, class_id].
Frigate config:
detectors:
ov:
type: openvino
device: GPU
model:
model_type: yolonas
width: 320
height: 320
input_tensor: nchw
input_pixel_format: bgr
path: /config/model_frigate.xml
Custom checkpoint export¶
Pre-exported artifacts¶
examples/export_zoo.py produces the whole cross product — every variant, at every
size, in every format — with a manifest.json recording each file's size, hash, and
what it will load on:
The ONNX and OpenVINO files are architecture-independent and cover ARM as they are.
TensorRT engines are built --hardware-compatible, because a natively built engine
is faster and loads on exactly one machine, which is no use to anyone downloading it.