Free course · by the SutraCore team

Free NVIDIA Jetson AI course: 30 days, 16 steps

Orin NX 16GBJetPack 6.2.1CUDA 12.6TensorRT 10.3Python 3.10PyTorch 2.8
Step 05 of 16Days 6–7 · Sat 31 Oct – Sun 1 NovWeek 1Phase A · notebooks

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.

Runs on
JupyterLab on the Jetson (TFLite cells may run on a laptop or Colab)
Notebooks
12_image_export.ipynb
Creates
exports/image_v1.onnx, image_v1_fp16.engine, image_v1_tf/*_full_integer_quant.tflite, image_v1_model_data.cc
Time
Day 6, plus Day 7 for review and cleanup

Where Where to implement

Notebook

  • notebooks/12_image_export.ipynb
  • Loads models/image_v1/best.pt from 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

Notebook cellnotebooks/12_image_export.ipynb
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

Notebook cellnotebooks/12_image_export.ipynb
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 pass

Why 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

Notebook cellnotebooks/12_image_export.ipynb
!/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.

Notebook cellnotebooks/12_image_export.ipynb
!/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):

Notebook cellnotebooks/12_image_export.ipynb
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.

OptionEffortStatus
A · Google Colab (recommended)Free, nothing to install, about 5 minutesSame 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.12uv 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):

Laptop terminalWindows 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.

Notebook cellGoogle Colab · cell 1
!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
Notebook cellGoogle Colab · cell 2
!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:

Laptop terminalWindows PowerShell
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:

Jetson terminal
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 onnxsim tool by name, so the converter venv's bin folder must be on PATH. Otherwise you get No 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.
Filesutra_core/export.py
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 tfl
Notebook cellnotebooks/12_image_export.ipynb (Option B only)
import 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 KB

Either 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

Notebook cellnotebooks/12_image_export.ipynb
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

Notebook cellnotebooks/12_image_export.ipynb
!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.cc

In 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_accuracy into sutra_core/export.py, next to onnx2tf_int8.
  • Write the week's numbers in BUILDLOG and commit.

Test Test it

Notebook cellnotebooks/12_image_export.ipynb (last cell)
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")
Pass: ONNX matches PyTorch, the engine builds and reports a latency, the int8 drop is under 3 points, and the C array exists.

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

SymptomFix
INVALID_ARGUMENT : Got invalid dimensions for input: input … index: 0 Got: 20 Expected: 1The 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 exportInformation only on torch 2.8. The current substep 2 cell passes dynamo=False and hides it.
Difference above 0.001 with the GPU providerONNX Runtime's CUDA provider may use TF32 on Orin. Compare on CPUExecutionProvider, as the cell does.
torch.onnx.export errorCheck 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 failedonnx2tf 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 existsA 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-tensorPer-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 onnx2tfHarmless warning from the converter venv's CPU-only ONNX Runtime. Ignore it.
Colab: No such file: image_v1.onnxUpload both files in cell 1 (the upload button appears under the cell) before running cell 2.
onnx2tf can't convert an opStay 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 dropUse 200+ calibration images from all classes; make sure the calibration data is raw 0–1 (onnx2tf applies mean and std).
trtexec not foundsudo apt install -y nvidia-jetpack; the path is /usr/src/tensorrt/bin/trtexec.

Learn more