Export and validate
Prove the model survives conversion: ONNX for everything, a TensorRT engine for the Jetson, and int8 TFLite plus a C array for ESP32 and Silicon Labs. This is the heart of edge AI.
Where Where to implement
Notebook
notebooks/12_image_export.ipynb- Loads
models/image_v1/best.ptfrom Step 04
Reusable code
- When it works, move the helpers into
sutra_core/export.py - You will reuse them in Steps 06, 07 and 09
Do Do it
1Rebuild the model and data in the notebook
import json, numpy as np, timm, torch
from pathlib import Path
from torchvision import datasets, transforms as T
ROOT = Path.home() / "sutraai"; EXP = ROOT / "exports"; EXP.mkdir(exist_ok=True)
SIZE, mean, std = 96, [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]
eval_tf = T.Compose([T.Resize(int(SIZE * 1.15)), T.CenterCrop(SIZE), T.ToTensor(), T.Normalize(mean, std)])
raw_tf = T.Compose([T.Resize(int(SIZE * 1.15)), T.CenterCrop(SIZE), T.ToTensor()]) # 0–1, no normalize
test_ds = datasets.ImageFolder(ROOT / "data/processed/images/test", eval_tf)
classes = test_ds.classes
model = timm.create_model("mobilenetv2_050", pretrained=False, num_classes=len(classes))
model.load_state_dict(torch.load(ROOT / "models/image_v1/best.pt", map_location="cpu"))
model.eval()
X = torch.stack([test_ds[i][0] for i in range(len(test_ds))]); y = np.array(test_ds.targets)
with torch.no_grad(): float_acc = float((model(X).argmax(1).numpy() == y).mean())
print("float32 test accuracy:", round(float_acc, 3))Expect the same number as Step 04's test accuracy (for example 0.822). That proves the right model and the right test photos were loaded. Step 05 judges the export by how much accuracy it loses compared with this number, not by the 85% target, so you can do Step 05 now and re-run it after improving the model.
Reading numbers like 2.18e-05: "e-05" means "move the decimal point 5 places left", so 2.18e-05 = 0.0000218.
2Export to ONNX and check it matches
import onnxruntime as ort, warnings
warnings.filterwarnings("ignore", message="You are using the legacy TorchScript-based ONNX export") # expected on torch 2.8, see below
onnx_path = EXP / "image_v1.onnx"
torch.onnx.export(model, torch.randn(1, 3, SIZE, SIZE), str(onnx_path), dynamo=False, # classic exporter: simplest graph for TensorRT + onnx2tf
input_names=["input"], output_names=["logits"], opset_version=17)
# The model is exported with a fixed batch size of 1 (what TensorRT and microcontrollers use),
# so the check below feeds it one image at a time.
sess = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"]) # CPU = exact float32, a fair comparison
n = min(20, len(X))
with torch.no_grad(): ref = model(X[:n]).numpy()
out = np.concatenate([sess.run(None, {"input": X[i:i + 1].numpy()})[0] for i in range(n)])
onnx_diff = float(np.abs(ref - out).max())
print("max difference PyTorch vs ONNX:", onnx_diff) # expect: below 0.001, e.g. 2e-05 (= 0.00002) is a passWhy these choices: torch 2.8 prints a DeprecationWarning about the "legacy TorchScript-based ONNX export". That's information, not an error: from torch 2.9 a newer exporter becomes the default. The classic one gives the plainest graph for TensorRT and onnx2tf, so the code asks for it explicitly with dynamo=False and hides the message. The comparison runs on ONNX Runtime's CPU provider because its GPU provider may use TF32 maths on Orin, which is slightly less exact and can push the difference past 0.001 even when the export is perfect.
3Build a TensorRT engine and time it
!/usr/src/tensorrt/bin/trtexec --onnx={onnx_path} --saveEngine={EXP}/image_v1_fp16.engine --fp16 2>&1 | grep -E "Latency|Throughput|PASSED|FAILED"Write the mean latency in BUILDLOG. This engine is built by TensorRT 10.3.0.30 for the Orin GPU (arch 8.7). An engine only runs on the same GPU type and TensorRT version that built it, so for other Jetsons, or after a JetPack upgrade, you rebuild it from the ONNX file with this command.
3bOptional: try the Orin NX's DLA
Your Orin NX has DLAs (deep-learning accelerators): low-power engines next to the GPU. Not every layer runs on a DLA; --allowGPUFallback sends the rest to the GPU. Compare latency, and power in jtop, against the GPU engine above.
!/usr/src/tensorrt/bin/trtexec --onnx={onnx_path} --saveEngine={EXP}/image_v1_dla0.engine --fp16 --useDLACore=0 --allowGPUFallback 2>&1 | grep -E "Latency|DLA|PASSED|FAILED"4Convert to int8 TFLite with real calibration images
int8 stores numbers as 8-bit integers: about 4× smaller and fast on microcontrollers. Calibration images tell the converter the real value ranges.
a) Save calibration images (100 real training photos, raw 0–1, in N,H,W,C order):
calib_ds = datasets.ImageFolder(ROOT / "data/processed/images/train", raw_tf)
idx = np.random.default_rng(0).choice(len(calib_ds), size=min(100, len(calib_ds)), replace=False)
calib = np.stack([calib_ds[i][0].permute(1, 2, 0).numpy() for i in idx]).astype(np.float32) # N,H,W,C in 0–1
np.save(EXP / "calib_image.npy", calib)
print(calib.shape) # e.g. (100, 96, 96, 3)b) Pick where the converter runs. The converter is onnx2tf 2.6.9. It no longer needs TensorFlow, but it needs Python 3.12 or newer, and your Jetson's system Python is 3.10. The last onnx2tf for Python 3.10 (1.29.x) pins old packages that have no ARM wheels and must compile for a long time on the Jetson, so these pages don't use it.
| Option | Effort | Status |
|---|---|---|
| A · Google Colab (recommended) | Free, nothing to install, about 5 minutes | Same command tested with onnx2tf 2.6.9 on x86-64 Linux: produces image_v1_full_integer_quant.tflite with int8 input and output |
| B · On the Jetson with a separate Python 3.12 | uv downloads Python 3.12; two small packages compile from source (10–30 min) | Install confirmed on a Jetson Orin NX 16GB with JetPack 6.2.1 (onnx2tf -h runs). If the install fails on your board, use A. |
Option A · Colab. Copy the two files from the Jetson to your laptop (Windows PowerShell):
mkdir C:\sutraai-export -Force
scp <your-user>@<jetson-ip>:~/sutraai/exports/image_v1.onnx <your-user>@<jetson-ip>:~/sutraai/exports/calib_image.npy C:\sutraai-export\Open colab.research.google.com → New notebook, and run these two cells. Pip may print dependency warnings about Colab's own packages; ignore them, because onnx2tf runs as a separate command.
!pip install -q onnx2tf==2.6.9
from google.colab import files
files.upload() # choose image_v1.onnx and calib_image.npy from C:\sutraai-export!onnx2tf -i image_v1.onnx -o image_v1_tf -oiqt -qt per-tensor -cind input calib_image.npy "[[[[0.485,0.456,0.406]]]]" "[[[[0.229,0.224,0.225]]]]"
# it may end with an error about "int16_act": fine, as long as the next line lists image_v1_full_integer_quant.tflite
!ls image_v1_tf/*.tflite
files.download("image_v1_tf/image_v1_full_integer_quant.tflite")Copy the downloaded file back to the Jetson:
ssh <your-user>@<jetson-ip> "mkdir -p ~/sutraai/exports/image_v1_tf"
scp $HOME\Downloads\image_v1_full_integer_quant.tflite <your-user>@<jetson-ip>:~/sutraai/exports/image_v1_tf/Option B · on the Jetson. A separate converter venv with its own Python 3.12, so your locked Python 3.10 / torch 2.8.0 / numpy 1.26.4 venv stays untouched:
sudo apt-get install -y cmake build-essential # needed to compile onnxoptimizer and psutil
curl -LsSf https://astral.sh/uv/install.sh | sh # installs uv into ~/.local/bin
cd ~/sutraai
~/.local/bin/uv venv --python 3.12 .venv-convert # downloads a standalone Python 3.12 just for this venv
~/.local/bin/uv pip install --python .venv-convert/bin/python onnx2tf==2.6.9
.venv-convert/bin/onnx2tf -h 2>/dev/null | head -n 3 # converter works: prints "usage: onnx2tf [-h] ..."Every onnx2tf run on the Jetson first prints [W:onnxruntime:Default, device_discovery.cc] Failed to detect devices under "/sys/class/drm/card0". It's a harmless warning (the W stands for warning): the converter's own CPU-only ONNX Runtime looks for a desktop-style graphics card in /sys/class/drm, and the Jetson's built-in GPU doesn't have that file. The conversion runs on the CPU anyway. The helper below captures onnx2tf's output, so you won't see it there. When you run onnx2tf by hand, add 2>&1 | grep -v device_discovery to hide just that line.
Two onnx2tf 2.6.9 quirks need handling, so the conversion goes through a small helper you save once and reuse in Steps 06, 07 and 09:
- onnx2tf runs its
onnxsimtool by name, so the converter venv'sbinfolder must be onPATH. Otherwise you getNo such file or directory: 'onnxsim'. - Its default per-channel int8 fails on MobileNetV2's depthwise layers (
quantized_dimension must be in range [0, 1). Was 3). Per-tensor works. It then still stops with an error while writing an optional int16 variant, after the full-integer file is written and checked. The helper only cares that this file exists.
import os, shutil, subprocess
from pathlib import Path
CONVERTER = Path.home() / "sutraai/.venv-convert/bin" # Python 3.12 venv with onnx2tf 2.6.9 (Step 05)
def onnx2tf_int8(onnx_path, out_dir, calib_npy, mean, std, per_tensor=True, backend=None, converter=CONVERTER):
"""ONNX → full-integer int8 TFLite. Returns the path of <name>_full_integer_quant.tflite.
per_tensor=True works around an onnx2tf 2.6.9 bug: per-channel quantization of depthwise
convolutions (MobileNetV2) fails validation. onnx2tf may still exit with an error while writing
the optional int16-activation variants; that's fine as long as the full-integer file exists."""
onnx_path, out_dir, converter = Path(onnx_path), Path(out_dir), Path(converter)
shutil.rmtree(out_dir, ignore_errors=True) # never pick up a stale file
env = {**os.environ, "PATH": f"{converter}:{os.environ.get('PATH', '')}"} # onnx2tf runs the onnxsim tool by name
cmd = [str(converter / "onnx2tf"), "-i", str(onnx_path), "-o", str(out_dir), "-oiqt",
"-cind", "input", str(calib_npy), mean, std]
if per_tensor: cmd += ["-qt", "per-tensor"]
if backend: cmd += ["-tb", backend]
r = subprocess.run(cmd, env=env, capture_output=True, text=True)
tfl = out_dir / f"{onnx_path.stem}_full_integer_quant.tflite"
if not tfl.exists():
print(r.stdout[-3000:], r.stderr[-3000:])
raise RuntimeError(f"onnx2tf did not create {tfl.name} (exit code {r.returncode})")
return tflimport sys; sys.path.insert(0, str(ROOT))
from sutra_core.export import onnx2tf_int8
tfl = onnx2tf_int8(onnx_path, EXP / "image_v1_tf", EXP / "calib_image.npy",
"[[[[0.485,0.456,0.406]]]]", "[[[[0.229,0.224,0.225]]]]")
print(tfl.name, round(tfl.stat().st_size / 1024, 1), "KB") # expect: image_v1_full_integer_quant.tflite, a few hundred KBEither way, use image_v1_full_integer_quant.tflite: int8 in, int8 out, exactly what TFLite Micro on a chip expects. The accuracy check in substep 5 runs on the Jetson with ai-edge-litert, already in your main venv. Steps 06, 07 and 09 use the same converter: with Option A, run their onnx2tf line in Colab the same way.
5Measure int8 accuracy with LiteRT
from ai_edge_litert.interpreter import Interpreter
def tflite_accuracy(path, X_nhwc, y):
"""Full-integer model. X_nhwc: float inputs already normalized, in N,H,W,C order."""
itp = Interpreter(model_path=str(path)); itp.allocate_tensors()
inp, out = itp.get_input_details()[0], itp.get_output_details()[0]
scale, zero = inp["quantization"]
hits = 0
for xi, yi in zip(X_nhwc, y):
q = np.clip(np.round(xi / scale + zero), -128, 127).astype(inp["dtype"])
itp.set_tensor(inp["index"], q[None]); itp.invoke()
hits += int(itp.get_tensor(out["index"])[0].argmax() == yi)
return hits / len(y)
tfl = EXP / "image_v1_tf" / "image_v1_full_integer_quant.tflite"
int8_acc = tflite_accuracy(tfl, X.permute(0, 2, 3, 1).numpy(), y)
print(f"float32 {float_acc:.3f} → int8 {int8_acc:.3f} (drop {100 * (float_acc - int8_acc):.1f} points)")
print("int8 model size:", round(tfl.stat().st_size / 1024, 1), "KB")6Make the C array for microcontrollers
!cd {EXP} && xxd -i image_v1_tf/image_v1_full_integer_quant.tflite > image_v1_model_data.cc && head -c 300 image_v1_model_data.ccIn Month 3 this file goes into ESP-IDF or Simplicity Studio. Mark the array alignas(16) const when you use it on a chip.
7Day 7: review
- Restart and Run All on NB10, NB11 and NB12.
- Move
tflite_accuracyintosutra_core/export.py, next toonnx2tf_int8. - Write the week's numbers in BUILDLOG and commit.
Test Test it
assert onnx_diff < 1e-3, f"ONNX differs from PyTorch by {onnx_diff}"
assert (EXP / "image_v1_fp16.engine").exists(), "TensorRT engine missing"
assert float_acc - int8_acc < 0.03, f"int8 lost {100 * (float_acc - int8_acc):.1f} points: use more or better calibration images"
assert (EXP / "image_v1_model_data.cc").exists()
print("✅ export path OK")Done Done when
- ONNX export matches PyTorch (max difference < 0.001)
- TensorRT fp16 engine built; latency in BUILDLOG
- int8 TFLite accuracy drop under 3 points
- C array generated
- Export helpers live in sutra_core/export.py
- Week 1 numbers committed to git
If it breaks
| Symptom | Fix |
|---|---|
INVALID_ARGUMENT : Got invalid dimensions for input: input … index: 0 Got: 20 Expected: 1 | The ONNX model has a fixed batch size of 1 (on purpose: TensorRT and microcontrollers run one image at a time), and the old check fed it 20 at once. Use the current substep 2 cell, which feeds images one by one. No need to re-export. |
DeprecationWarning: You are using the legacy TorchScript-based ONNX export | Information only on torch 2.8. The current substep 2 cell passes dynamo=False and hides it. |
| Difference above 0.001 with the GPU provider | ONNX Runtime's CUDA provider may use TF32 on Orin. Compare on CPUExecutionProvider, as the cell does. |
torch.onnx.export error | Check torch.__version__ is 2.8.0 and keep opset_version=17 and dynamo=False. |
Installing onnx2tf on the Jetson takes forever or fails (compiling onnxsim, onnxoptimizer, h5py, psutil) | pip picked the old onnx2tf 1.29 for Python 3.10. Use Option A (Colab), or Option B with uv venv --python 3.12 and onnx2tf==2.6.9. TensorFlow isn't needed any more. |
FileNotFoundError: … No such file or directory: 'onnxsim' | The converter venv's bin isn't on PATH when onnx2tf is called by its full path. Use onnx2tf_int8() from sutra_core/export.py, which sets it. |
quantized_dimension must be in range [0, 1). Was 3 … flatbuffer_direct fast path failed | onnx2tf 2.6.9 bug with per-channel int8 on depthwise convolutions (MobileNetV2). Add -qt per-tensor; onnx2tf_int8() does by default. |
Error about integer_quant_with_int16_act / INT32 != INT64, but image_v1_full_integer_quant.tflite exists | A second onnx2tf bug in an optional extra file, raised after the int8 file you need was written and checked. Ignore it. |
| int8 drop above 3 points with per-tensor | Per-tensor is less precise on depthwise layers. Try the TensorFlow converter backend (not tested on the Jetson yet): ~/.local/bin/uv pip install --python .venv-convert/bin/python tensorflow tf-keras, then onnx2tf_int8(..., per_tensor=False, backend="tf_converter"). Or use more varied calibration images. |
Failed to detect devices under "/sys/class/drm/card0" when running onnx2tf | Harmless warning from the converter venv's CPU-only ONNX Runtime. Ignore it. |
Colab: No such file: image_v1.onnx | Upload both files in cell 1 (the upload button appears under the cell) before running cell 2. |
| onnx2tf can't convert an op | Stay with MobileNetV2-family models for MCU targets; check the onnx2tf README. Its older TensorFlow-based path is still available with --tflite_backend tf_converter (needs tensorflow installed in the converter venv). |
| Big int8 drop | Use 200+ calibration images from all classes; make sure the calibration data is raw 0–1 (onnx2tf applies mean and std). |
| trtexec not found | sudo apt install -y nvidia-jetpack; the path is /usr/src/tensorrt/bin/trtexec. |
Learn more
- Udemy: Learn TensorFlow, PyTorch, TensorRT, ONNX From Scratch (check recent reviews first)
- onnx2tf on GitHub: all conversion options, including calibration
- LiteRT (TensorFlow Lite) documentation