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
| Crate | Type | Depends on | Role |
|---|---|---|---|
zentract-types | lib · no_std · forbid(unsafe_code) | — | Shared #[repr(C)] FFI types: TensorMeta, DType, ErrorCode, ABI_VERSION |
zentract-abi | cdylib | tract-onnx 0.22 | The plugin: links tract, exports the extern "C" entry points |
zentract-api | lib · deny(unsafe_code) | libloading, thiserror | Host-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— thecdylibfailed to open or is missing an exportAbiMismatch { expected, actual }— plugin built against a differentABI_VERSIONModelLoad(code)— ONNX parse/optimize failedInference(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):
| Artifact | Links | Approx. size |
|---|---|---|
libzentract_abi.{so,dll,dylib} | tract-onnx (the full engine) | ~16 MB |
host footprint added by zentract-api | libloading + 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.
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 internals | zenjxl-decoder · jxl-encoder · zenrav1e · rav1d-safe · zenavif-parse · zenavif-serialize |
| Compression | zenflate · zenzop · zenzstd |
| Processing | zenresize · zenquant · zenblend · zenfilters · zensally · zentone |
| Pixels & color | zenpixels · zenpixels-convert · linear-srgb · garb |
| Pipeline & framework | zenpipe · zencodec · zencodecs · zenlayout · zennode · zenwasm · zentract |
| Metrics | zensim · fast-ssim2 · butteraugli · zenmetrics · resamplescope-rs |
| Pickers & ML | zenanalyze · zenpredict · zenpicker |
| Products | Imageflow 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