zentract [](https://github.com/imazen/zentract/actions/workflows/ci.yml) [](https://crates.io/crates/zentract-api) [](https://lib.rs/crates/zentract-api) [](https://docs.rs/zentract-api) [](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field) [](#license)

June 28, 2026 · View on GitHub

zentract puts the tract ONNX inference engine behind a C ABI in a cdylib, so your application loads it at runtime with dlopen instead of statically linking it. Your binary depends only on the small zentract-api host loader (libloading + thiserror); tract and its large transitive tree compile once, into the plugin, and stay out of your build.

Quick start

[dependencies]
zentract-api = "0.1.0"   # host loader only — no tract dependency

Build the plugin once (cargo build --release -p zentract-abi produces libzentract_abi.so / .dll / .dylib), ship it next to your binary, then load and run a model:

use zentract_api::{InferenceEngine, TensorMeta};

// Load the plugin once, at startup.
let engine = InferenceEngine::load("libzentract_abi.so")?;

// Load an ONNX model with a fixed input shape.
let onnx = std::fs::read("model.onnx")?;
let input_shape = TensorMeta::f32_shape(&[1, 3, 320, 320]);
let model = engine.load_onnx(&onnx, input_shape)?;

// f32 tensor in, f32 tensor out.
let input: Vec<f32> = vec![0.0; 3 * 320 * 320]; // N*C*H*W elements
let output = model.infer(&input, 0)?;           // output_index = 0
let scores: &[f32] = &output.data;              // output.meta holds the shape

ModelHandle frees its model when dropped; the InferenceEngine stays loaded as long as you hold it.

Why

tract-onnx is a well-maintained, pure-Rust ONNX runtime — but it's a large dependency. The workspace lockfile resolves to ~140 crates (the bulk of them tract's seven sub-crates and their dependencies), and that tree dominates compile time for any project that links it. If all you need is "f32 tensor in, f32 tensor out," there's no reason to rebuild the engine into every consumer.

zentract draws the line at a C ABI: the engine lives in a cdylib, your app links only the loader. You trade a dlopen call and a copied-out output buffer for a small, fast-compiling host crate and a plugin you can rebuild and distribute independently.

Crates

CrateTypeDepends onRole
zentract-typeslib · no_std · forbid(unsafe_code)Shared #[repr(C)] FFI types: TensorMeta, DType, ErrorCode, ABI_VERSION
zentract-abicdylibtract-onnx 0.22The plugin: links tract, exports the extern "C" entry points
zentract-apilib · deny(unsafe_code)libloading, thiserrorHost-side loader; no tract dependency

Detached handles

By default a ModelHandle borrows its InferenceEngine and frees the model on drop — the safe, common case. When you need a model to outlive that borrow (store it in a struct, hand it across an API boundary, or manage its lifetime yourself), detach it to a raw i64 handle:

let model = engine.load_onnx(&onnx, input_shape)?;
let raw: i64 = model.into_raw();          // model is NOT freed on drop

// ...later, run inference directly on the raw handle...
let output = engine.infer_raw(raw, &input, 0)?;

// You now own the lifetime — free it explicitly.
engine.free_raw(raw);

into_raw suppresses the Drop, so a matching free_raw is mandatory or the model leaks inside the plugin.

Errors

zentract-api returns a single Error enum (via thiserror):

  • LoadLibrary — the cdylib failed to open or is missing an export
  • AbiMismatch { expected, actual } — plugin built against a different ABI_VERSION
  • ModelLoad(code) — ONNX parse/optimize failed
  • Inference(code) — a run failed (e.g. shape mismatch)
  • InvalidHandle — the handle doesn't refer to a loaded model

ModelHandle::output_count() reports how many outputs a model exposes, so you can validate an output_index before calling infer.

Threading

The plugin keeps loaded models in thread-local storage, so a model handle is only valid on the thread that loaded it — load and run each model on the same thread. InferenceEngine itself is Send + Sync and may be shared across threads; it's the per-thread handles that you must not move between threads.

ABI contract

The plugin exports five extern "C" functions (defined in zentract-abi/src/lib.rs):

uint32_t zentract_abi_version(void);
int64_t  zentract_load(const uint8_t *onnx, size_t len, const TensorMeta *input); // handle >= 0, else negative ErrorCode
int32_t  zentract_infer(int64_t handle, const float *input, size_t len, uint32_t output_index,
                        const float **out_data, size_t *out_len, TensorMeta *out_meta); // 0 = Ok
int32_t  zentract_output_count(int64_t handle);
void     zentract_free(int64_t handle);

out_data points into memory owned by the plugin and is valid only until the next zentract_infer or zentract_free on the same handle. zentract-api copies it into an owned Vec<f32> before returning, so host code never holds a dangling pointer. ABI_VERSION (currently 1) is checked at load time and lives in zentract-types; bump it on any breaking change to these signatures.

Building

Both plugin and host live in one workspace:

cargo build --release            # builds all three crates
# target/release/libzentract_abi.so   <- the plugin; ship this alongside your binary

Input tensors must be DType::F32, and the model's input shape is fixed at load time. TensorMeta carries up to MAX_NDIM (8) dimensions.

Binary footprint

The design goal is to keep tract out of your binary. Approximate stripped sizes (they vary with platform, tract version, and your own code):

ArtifactLinksApprox. size
libzentract_abi.{so,dll,dylib}tract-onnx (the full engine)~16 MB
host footprint added by zentract-apilibloading + thiserror~350 KB

The durable point isn't the exact figures — it's the ratio: tens of megabytes of engine compile once into a plugin you ship as a file, while every consumer rebuilds only a few hundred KB of loader.

Platform support

CI covers Linux, macOS, and Windows on x86-64, plus Linux and macOS on ARM64. Windows on ARM64 is not built: tract's linear-algebra kernels ship ARM64 GAS assembly that the MSVC toolchain can't assemble. On any target tract doesn't support, link tract directly rather than through the plugin.

License

Dual-licensed: AGPL-3.0 or commercial.

I've maintained and developed open-source image server software — and the 40+ library ecosystem it depends on — full-time since 2011. Fifteen years of continual maintenance, backwards compatibility, support, and the (very rare) security patch. That kind of stability requires sustainable funding, and dual-licensing is how we make it work without venture capital or rug-pulls. Support sustainable and secure software; swap patch tuesday for patch leap-year.

Our open-source products

Your options:

  • Startup license — $1 if your company has under $1M revenue and fewer than 5 employees. Get a key →
  • Commercial subscription — Governed by the Imazen Site-wide Subscription License v1.1 or later. Apache 2.0-like terms, no source-sharing requirement. Sliding scale by company size. Pricing & 60-day free trial →
  • AGPL v3 — Free and open. Share your source if you distribute.

See LICENSE-COMMERCIAL for details.

Image tech I maintain

Codecs ¹zenjpeg · zenpng · zenwebp · zengif · zenavif · zenjxl · zenbitmaps · heic · zentiff · zenpdf · zensvg · zenjp2 · zenraw · ultrahdr
Codec internalszenjxl-decoder · jxl-encoder · zenrav1e · rav1d-safe · zenavif-parse · zenavif-serialize
Compressionzenflate · zenzop · zenzstd
Processingzenresize · zenquant · zenblend · zenfilters · zensally · zentone
Pixels & colorzenpixels · zenpixels-convert · linear-srgb · garb
Pipeline & frameworkzenpipe · zencodec · zencodecs · zenlayout · zennode · zenwasm · zentract
Metricszensim · fast-ssim2 · butteraugli · zenmetrics · resamplescope-rs
Pickers & MLzenanalyze · zenpredict · zenpicker
ProductsImageflow image engine (.NET · Node · Go) · Imageflow Server · ImageResizer (C#)

¹ pure-Rust, #![forbid(unsafe_code)] codecs, as of 2026

General Rust awesomeness

zenbench · archmage · magetypes · enough · whereat · cargo-copter

Open source · @imazen · @lilith · lib.rs/~lilith