Deploying CV Models: ONNX, TensorRT, Edge, and APIs
In this series (30 parts)
- Computer Vision Roadmap: From Basics to Real Projects
- What is Computer Vision? From Pixels to Decisions
- A Short History of Computer Vision: 1545 to Now
- Math for CV Beginners: Vectors, Matrices, Convolutions
- Image Fundamentals: Color, Histograms, Noise, Filtering
- How Images and Video Are Stored: Colour, JPEG, Frames
- OpenCV Setup + First 10 Tasks in Python
- Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
- CV Project Workflow: Dataset, Baseline, Error Iteration
- Intensity Transforms and Frequency-Domain Filtering
- Edge Detection and Thresholding That Actually Work
- Morphology, Contours, and Shape Analysis for Real Images
- Feature Matching (SIFT/ORB) and Image Stitching
- Camera Calibration and Perspective Correction
- Epipolar Geometry, Stereo Vision, and Depth Estimation
- Optical Flow and Motion Tracking in Video
- HOG, HOF and MBH: Descriptors Before Deep Learning
- Your First Image Classifier (PyTorch + Transfer Learning)
- Data Pipelines and Augmentation for Vision Models
- CNN Architectures Explained: From LeNet to ResNet
- YOLO Detection Pipeline: Data to Inference
- Semantic and Instance Segmentation: U-Net to Mask R-CNN
- Vision Transformers (ViT) and When to Use Them
- CV Explainability: Grad-CAM, Failures, Bias Checks
- 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
- Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
- Video Understanding: Flow Networks, Interpolation, Stabilisation
- Real-Time CV Systems: Tracking, Latency, Streaming
- Deploying CV Models: ONNX, TensorRT, Edge, and APIs
- 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.
flowchart LR subgraph "PyTorch: 3 kernels" A["Conv2d"] --> B["BatchNorm2d"] --> C["ReLU"] end subgraph "TensorRT: 1 kernel" D["Fused ConvBnReLU"] end C -.->|"fuse"| D
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.
Worked. A tensor’s observed float range is . Int8 covers .
Check the endpoints:
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:
The outlier problem
Everything above assumed the range was . Suppose one activation in the tensor hits 47.0.
| # | 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.
| Property | FP32 | FP16 | INT8 PTQ | INT8 QAT |
|---|---|---|---|---|
| Relative speed | 1.0× | 2.5–3× | 4–6× | 4–6× |
| Model size | 102 MB | 51 MB | 26 MB | 26 MB |
| Typical mAP loss | — | <0.001 | 0.005–0.02 | <0.005 |
| Effort | none | one flag | calibration set | retraining |
| When | debugging | always, if supported | the usual choice | when PTQ loses too much |
Cloud or edge
flowchart TD
A{"Where to run it?"} --> B{"Bandwidth available<br/>and affordable?"}
B -->|no| E["Edge"]
B -->|yes| C{"Latency budget<br/>under ~100 ms?"}
C -->|yes| E
C -->|no| D{"Can raw video<br/>leave the site?"}
D -->|no| E
D -->|yes| F{"Many sites,<br/>frequent model updates?"}
F -->|yes| G["Cloud"]
F -->|no| E
Run the numbers for the 8-camera junction.
Bandwidth if video goes to the cloud. 1080p H.264 at 4 Mbps per camera:
| # | 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.
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.
- You are compute-bound on edge hardware
- A few thousandths of mAP is an acceptable trade
- You have 100+ representative production images for calibration
- The target hardware has INT8 acceleration
- FP16 already meets the budget — it is simpler and nearly lossless
- The task needs fine numeric precision, such as precise measurement
- You cannot get representative calibration data
- You have not yet measured whether inference is actually the bottleneck
Practice task
Take any trained detector or classifier.
- Export to ONNX. Compare outputs against PyTorch and confirm agreement below 1e-4.
- Export again without
model.eval(). Measure how far the outputs diverge. - Time PyTorch, ONNX Runtime, and TensorRT FP32 on the same input. Build the table.
- Enable FP16. Measure latency and accuracy. Confirm accuracy barely moves.
- Compute the scale and zero point by hand for one layer’s observed range. Verify with the framework’s values.
- Calibrate INT8 with 100 real images, then 500, then with random noise. Compare accuracy.
- Inject one extreme activation into the calibration set. Watch the scale and the accuracy change.
- 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 : and , verified at all three endpoints. Maximum error is .
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.
- ONNX export then TensorRT INT8 took a detector from 142 ms to 4.9 ms on edge hardware.
- Always verify the ONNX export numerically — most bad exports still load and run.
- FP16 is close to free: 2.5-3x faster, half the size, accuracy loss inside evaluation noise.
- INT8 needs two numbers per tensor: scale = range/255, and a zero point so 0.0 is representable.
- Quantisation error is bounded by half the scale, exactly.
- One outlier activation stretches the scale and multiplies everyone else's error — clip at a percentile.
- Calibration needs 100-500 images, unlabelled, but they must match production conditions.
- Build TensorRT engines on the target device — they are specific to GPU model and TRT version.
- Warm up the engine before serving: the first inference can be 500 ms.
- Monitor detections per minute and mean confidence — they catch failures latency metrics never will.
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.