Search…

Deploying CV Models: ONNX, TensorRT, Edge, and APIs

In this series (30 parts)
  1. Computer Vision Roadmap: From Basics to Real Projects
  2. What is Computer Vision? From Pixels to Decisions
  3. A Short History of Computer Vision: 1545 to Now
  4. Math for CV Beginners: Vectors, Matrices, Convolutions
  5. Image Fundamentals: Color, Histograms, Noise, Filtering
  6. How Images and Video Are Stored: Colour, JPEG, Frames
  7. OpenCV Setup + First 10 Tasks in Python
  8. Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
  9. CV Project Workflow: Dataset, Baseline, Error Iteration
  10. Intensity Transforms and Frequency-Domain Filtering
  11. Edge Detection and Thresholding That Actually Work
  12. Morphology, Contours, and Shape Analysis for Real Images
  13. Feature Matching (SIFT/ORB) and Image Stitching
  14. Camera Calibration and Perspective Correction
  15. Epipolar Geometry, Stereo Vision, and Depth Estimation
  16. Optical Flow and Motion Tracking in Video
  17. HOG, HOF and MBH: Descriptors Before Deep Learning
  18. Your First Image Classifier (PyTorch + Transfer Learning)
  19. Data Pipelines and Augmentation for Vision Models
  20. CNN Architectures Explained: From LeNet to ResNet
  21. YOLO Detection Pipeline: Data to Inference
  22. Semantic and Instance Segmentation: U-Net to Mask R-CNN
  23. Vision Transformers (ViT) and When to Use Them
  24. CV Explainability: Grad-CAM, Failures, Bias Checks
  25. 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
  26. Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
  27. Video Understanding: Flow Networks, Interpolation, Stabilisation
  28. Real-Time CV Systems: Tracking, Latency, Streaming
  29. Deploying CV Models: ONNX, TensorRT, Edge, and APIs
  30. Responsible CV: Privacy, Fairness, Security, Governance

The traffic counting system needs 18 ms of inference to fit its budget, and it gets that on a desktop GPU. The council wants it in a weatherproof box on a lamppost, powered over Ethernet, with no fan. Same model, one twentieth of the compute.

Prerequisites: Real-time computer vision systems and CNN architectures.

What deployment actually changes

# Training machine Roadside edge box target
1 Device RTX 4090 Jetson Orin Nano
2 Compute (INT8) ~660 TOPS 40 TOPS
3 Memory 24 GB 8 GB shared
4 Power 450 W 7–15 W
5 Cooling fans passive, sealed box
6 Python available yes yes, but slow
7 Inference, FP32 PyTorch 18.0 ms 142.0 ms

The same model, unchanged, is 7.9x slower on the target. At 142 ms it cannot process even a single 30 fps stream.

Three things get it back: a faster runtime, lower numeric precision, and graph-level optimisation. They compound.

Step one: leave PyTorch

ONNX A file format that stores a model's computation graph and weights in a framework-neutral way, so it can be run by engines other than the one it was trained in.

PyTorch is built for flexibility — every operation dispatches dynamically, which is what makes debugging pleasant and inference slow. An ONNX export freezes the graph so a runtime can rewrite it.

dummy = torch.randn(1, 3, 640, 640, device='cuda')
model.eval()

torch.onnx.export(
    model, dummy, "detector.onnx",
    input_names=["images"], output_names=["output"],
    dynamic_axes={"images": {0: "batch"}, "output": {0: "batch"}},
    opset_version=17,
    do_constant_folding=True)

Then verify the export, because a silently wrong export is the most common deployment bug:

import onnxruntime as ort, numpy as np
sess = ort.InferenceSession("detector.onnx", providers=["CUDAExecutionProvider"])
x = np.random.randn(1, 3, 640, 640).astype(np.float32)

torch_out = model(torch.from_numpy(x).cuda()).detach().cpu().numpy()
onnx_out  = sess.run(None, {"images": x})[0]

print(np.abs(torch_out - onnx_out).max())   # want < 1e-4
# Export trap Symptom Fix
1 Forgot model.eval() BatchNorm uses batch stats, outputs differ Always call eval() first
2 No dynamic_axes Only the exact export batch size works Declare which axes vary
3 Python if/for on tensor values Only one branch is traced, silently Rewrite branch-free, or use torch.jit.script
4 tensor.shape used as a Python int Shape is baked in as a constant Use torch.Size ops that export
5 NMS included in the graph Unsupported or extremely slow op Export the raw head, do NMS outside
6 Opset too old Newer ops silently decomposed and slowed Use opset 17 or above

Six ways an ONNX export goes wrong. Five of them produce a file that loads and runs.

Step two: TensorRT

TensorRT NVIDIA's inference compiler. It fuses operations, picks the fastest kernel for the specific GPU it runs on, and can rewrite the graph to lower precision.

The biggest win is layer fusion. A conv → batchnorm → ReLU sequence is three kernel launches and three round trips to memory. TensorRT folds the batchnorm into the convolution’s weights and applies the ReLU inside the same kernel — one launch, one round trip.

trtexec --onnx=detector.onnx \
        --saveEngine=detector_int8.plan \
        --int8 \
        --calib=calibration.cache \
        --workspace=4096

The engine file is specific to the GPU model and the TensorRT version. Build it on the target device, not on your laptop, or it will refuse to load.

# Configuration Latency (ms) target Speedup Model size mAP@0.5
1 PyTorch FP32 (Jetson) 142.0 1.0× 102.4 MB 0.681
2 ONNX Runtime FP32 96.0 1.5× 102.4 MB 0.681
3 TensorRT FP32 41.0 3.5× 102.4 MB 0.681
4 TensorRT FP16 12.8 11.1× 51.2 MB 0.680
5 TensorRT INT8 4.9 29.0× 25.6 MB 0.673

A 25.6M-parameter detector on a Jetson Orin Nano. INT8 is 29x faster than PyTorch for 0.008 mAP.

FP16 is close to a free lunch. On any GPU with tensor cores it is faster, half the size, and the accuracy loss is smaller than the run-to-run variation of retraining. Enable it before considering anything more involved.

How INT8 quantisation works

A float32 number becomes an int8 number through two parameters.

scale How much real value one integer step represents. Range of the floats divided by the 255 available integer steps. zero point The integer that represents exactly 0.0. Needed because the float range is rarely symmetric around zero.

q=round ⁣(xs)+zx(qz)sq = \text{round}\!\left(\frac{x}{s}\right) + z \qquad\qquad x \approx (q - z) \cdot s

Worked. A tensor’s observed float range is [1.0,3.0][-1.0, 3.0]. Int8 covers [128,127][-128, 127].

s=xmaxxminqmaxqmin=3.0(1.0)127(128)=4.0255=0.0156863s = \frac{x_{\max} - x_{\min}}{q_{\max} - q_{\min}} = \frac{3.0 - (-1.0)}{127 - (-128)} = \frac{4.0}{255} = 0.0156863

z=qminround ⁣(xmins)=128round ⁣(1.00.0156863)=128(64)=64z = q_{\min} - \text{round}\!\left(\frac{x_{\min}}{s}\right) = -128 - \text{round}\!\left(\frac{-1.0}{0.0156863}\right) = -128 - (-64) = -64

Check the endpoints:

x=1.0    round(63.75)+(64)=6464=128  x = -1.0 \;\rightarrow\; \text{round}(-63.75) + (-64) = -64 - 64 = -128 \;\checkmark x=3.0    round(191.25)+(64)=19164=127  x = 3.0 \;\rightarrow\; \text{round}(191.25) + (-64) = 191 - 64 = 127 \;\checkmark x=0.0    0+(64)=64=z  x = 0.0 \;\rightarrow\; 0 + (-64) = -64 = z \;\checkmark

Now round-trip some real values:

# Original x x / s rounded q = +z Dequantised Error target
1 1.500 95.625 96 32 1.50589 0.00589
2 −0.300 −19.125 −19 −83 −0.29804 0.00196
3 2.900 184.875 185 121 2.90196 0.00196
4 0.000 0.000 0 −64 0.00000 0.00000
5 2.573 164.03 164 100 2.57255 0.00045

Round-trip through INT8. Every error is below half the scale.

The error bound is exact and worth remembering:

max error=s2=0.01568632=0.00784\text{max error} = \frac{s}{2} = \frac{0.0156863}{2} = 0.00784

float32 values (2×2)
1.5
-0.3
2.9
2.573
quantise
int8 (s=0.0156863, z=-64) (2×2)
32
-83
121
100
dequantise
dequantised (2×2)
1.506
-0.298
2.902
2.573

The outlier problem

Everything above assumed the range was [1.0,3.0][-1.0, 3.0]. Suppose one activation in the tensor hits 47.0.

s=47.0(1.0)255=48.0255=0.188235s' = \frac{47.0 - (-1.0)}{255} = \frac{48.0}{255} = 0.188235

max error=0.1882352=0.09412\text{max error}' = \frac{0.188235}{2} = 0.09412

# Calibration range Scale Max error target Error vs baseline mAP
1 [−1.0, 3.0] (99.99th percentile) 0.015686 0.00784 1.0× 0.673
2 [−1.0, 8.0] 0.035294 0.01765 2.3× 0.669
3 [−1.0, 47.0] (absolute min/max) 0.188235 0.09412 12.0× 0.598

One outlier costs 0.075 mAP. Every ordinary value now quantises 12x more coarsely.

This is why calibration uses a percentile rather than the true maximum. Clipping 0.01% of values to the boundary is a far better trade than making the other 99.99% twelve times less precise.

Calibration data

INT8 needs to observe real activations to pick ranges. It does not need labels and it does not need many images.

# Calibration set Images mAP after INT8 target Note
1 Random noise 500 0.412 Ranges are meaningless
2 Different domain (COCO) 500 0.641 Better, still mismatched
3 Real production frames 100 0.671 Good
4 Real production frames 500 0.673 Best — diminishing returns
5 Real production frames 5000 0.673 No further gain

Calibration set size and source. 100 real images beat 500 out-of-domain ones.

The set must cover your conditions: night, rain, low sun, and heavy traffic. Calibrating only on clear daytime frames sets ranges that clip at night, and the model degrades exactly when it matters.

PropertyFP32FP16INT8 PTQINT8 QAT
Relative speed1.0×2.5–3×4–6×4–6×
Model size102 MB51 MB26 MB26 MB
Typical mAP loss<0.0010.005–0.02<0.005
Effortnoneone flagcalibration setretraining
Whendebuggingalways, if supportedthe usual choicewhen PTQ loses too much
Precision options. Post-training quantisation is nearly always enough; quantisation-aware training is the fallback.

Cloud or edge

Run the numbers for the 8-camera junction.

Bandwidth if video goes to the cloud. 1080p H.264 at 4 Mbps per camera:

8×4 Mbps=32 Mbps8 \times 4 \text{ Mbps} = 32 \text{ Mbps} 32×106×86,400÷8=345.6 GB per day32 \times 10^6 \times 86{,}400 \div 8 = 345.6 \text{ GB per day} 345.6×365=126,144 GB=126 TB per year345.6 \times 365 = 126{,}144 \text{ GB} = 126 \text{ TB per year}

# Cost item Cloud target Edge
1 Hardware £0 £600 (Jetson + enclosure)
2 Compute, year 1 £6,570 (£0.75/hr GPU) £0
3 Egress / data, year 1 £2,400 (126 TB) £120 (counts only)
4 Connectivity £1,200 (high-bandwidth link) £180 (4G, low volume)
5 Year 1 total £10,170 £900
6 Year 3 total £30,510 £1,500
7 Round-trip latency 60–200 ms 5 ms
8 Raw video leaves site yes no

One junction, three years. The edge box costs 5% of the cloud option and never sends a face anywhere.

The bandwidth line is what decides it in practice. Publishing counts instead of video reduces the data by roughly four orders of magnitude — a JSON object of a few hundred bytes once per second is about 26 MB a year per camera.

Making it robust

class Detector:
    def __init__(self, engine_path):
        self.engine = load_engine(engine_path)
        self.warm_up()          # first inference is 10-50x slower

    def warm_up(self):
        dummy = np.zeros((1, 3, 640, 640), dtype=np.float32)
        for _ in range(10):
            self.engine.infer(dummy)

    def predict(self, frame):
        if frame is None or frame.size == 0:
            return []
        try:
            return self.engine.infer(self.preprocess(frame))
        except Exception:
            self.log_error()
            return []           # a missed frame, not a crashed service

The warm-up is not optional. The first inference through a TensorRT engine allocates workspace and loads kernels, and it can take 500 ms. Without warm-up, the first real frame blows the latency budget and the drop counter spikes at every restart.

# What to monitor Healthy Alert when Catches
1 p99 inference latency < 6 ms > 10 ms Thermal throttling, contention
2 Frame drop rate < 0.5% > 5% Falling behind
3 Detections per minute 40–300 0 for 5 min Camera moved, lens fogged, feed frozen
4 Mean detection confidence 0.62–0.78 shifts > 0.1 Domain drift — new lighting, season
5 Device temperature < 70 °C > 80 °C Enclosure failure in summer
6 Age of newest result < 2 s > 10 s Stalled pipeline

Six signals. The middle two catch failures that no technical metric would.

domain drift Production input gradually moving away from the training distribution, so accuracy degrades without any code or model change.

Drift on outdoor cameras is seasonal and guaranteed. A model calibrated in June meets low winter sun, wet reflective tarmac, and four hours less daylight. Mean detection confidence is the cheapest early warning, because it needs no labels.

Practice task

Take any trained detector or classifier.

  1. Export to ONNX. Compare outputs against PyTorch and confirm agreement below 1e-4.
  2. Export again without model.eval(). Measure how far the outputs diverge.
  3. Time PyTorch, ONNX Runtime, and TensorRT FP32 on the same input. Build the table.
  4. Enable FP16. Measure latency and accuracy. Confirm accuracy barely moves.
  5. Compute the scale and zero point by hand for one layer’s observed range. Verify with the framework’s values.
  6. Calibrate INT8 with 100 real images, then 500, then with random noise. Compare accuracy.
  7. Inject one extreme activation into the calibration set. Watch the scale and the accuracy change.
  8. Measure the first inference against the hundredth. That gap is why warm-up exists.

Step 7 makes the outlier argument concrete. A single artificial value can move mAP by several points, which is far more dramatic than the explanation suggests.

Summary

The same 25.6M-parameter model went from 142 ms in PyTorch on the Jetson to 4.9 ms in TensorRT INT8 — 29× — for 0.008 mAP. FP16 alone gave 11× for 0.001, which is inside evaluation noise.

INT8 is a scale and a zero point. For a range of [1.0,3.0][-1.0, 3.0]: s=4.0/255=0.0156863s = 4.0/255 = 0.0156863 and z=64z = -64, verified at all three endpoints. Maximum error is s/2=0.00784s/2 = 0.00784.

One outlier at 47.0 pushed the scale to 0.188235 and the error to 0.09412 — twelve times worse for every ordinary value, costing 0.075 mAP. Calibrate on a percentile, not the true maximum.

One hundred real production images beat 500 out-of-domain ones for calibration. Random noise was worthless.

Streaming eight cameras to the cloud is 126 TB a year and £30,510 over three years. The edge box is £1,500 and no video leaves the site.

What comes next

The system now runs fast, cheaply, and on a lamppost. That raises questions that have nothing to do with latency: who is in the frames, who checked whether it works equally well for everyone, and what happens when it is wrong. Responsible computer vision covers privacy, fairness measurement, adversarial robustness, and the release checks worth running before anything goes live.

Test your understanding
INT8 quantisation drops your model's mAP from 0.673 to 0.598. FP16 loses almost nothing. What should you check first?
Test your understanding
Your TensorRT engine built and benchmarked perfectly on the dev machine but fails to load on the roadside Jetson. Why?

Frequently asked questions

Do I need TensorRT, or is ONNX Runtime enough?
ONNX Runtime alone gave a 1.5x speedup here and is far simpler — one pip install, one portable file, works on CPU and GPU and across vendors. TensorRT gave 3.5x at FP32 and 29x at INT8, but requires building per device and locks you to NVIDIA. Start with ONNX Runtime, measure against your budget, and only take on TensorRT if you still need the gap closed.
Will quantisation always lose accuracy?
A little, and usually far less than people expect: 0.005 to 0.02 mAP for post-training INT8 on a well-calibrated detector. Larger drops nearly always mean a calibration problem rather than a fundamental limit, most often outliers setting the scale or calibration data that does not match production. If post-training quantisation genuinely cannot get close enough, quantisation-aware training simulates the rounding during fine-tuning and typically recovers most of the gap.
Can I quantise only part of the model?
Yes, and mixed precision is a standard technique. The first and last layers are the usual candidates to leave in higher precision, since they tend to have wider dynamic ranges and there are few of them so the cost is small. TensorRT can do this automatically when you give it an accuracy target, and layer-wise sensitivity analysis will tell you which layers actually matter if you want to choose manually.
How do I update a model on hundreds of edge devices?
Stage it. Push to a small canary group, compare their detection rates and confidence distributions against the rest of the fleet for a day, then roll out gradually with the ability to revert. Keep the previous engine on the device so a rollback is a symlink change rather than a download. And remember the engine must be built for that device class, so your build pipeline needs one artefact per hardware variant.
What if the edge device cannot keep up even after quantisation?
Work through the cheap options in order: detect every Nth frame and track between, which typically gives 2 to 3x; reduce input resolution, since cost scales with area so 640 to 512 is a 36% saving; then move to a smaller model variant. Only after those should you consider more hardware. The detect-and-track change alone is usually larger than the difference between two hardware generations.
Start typing to search across all content
navigate Enter open Esc close