Object detection in pure Rust.
Diana runs the real YOLO26 graph — C3k2, SPPF, C2PSA, attention, and the NMS-free one2one head — on candle, from official Ultralytics checkpoints converted offline. No Python runtime, no ONNX, no gated models, and no vendored weights.
It is the detection component of FFai, named for the Roman goddess of the hunt.
[dependencies]
ffai-diana = "0.6"
ffai-core = "0.6"
ffai-media = "0.6"Against Ultralytics 8.4.113 and ONNX Runtime on a hash-pinned 45-image COCO
holdout, CPU only, yolo26n at 640 rect
(bench-detect-1785728764):
| mAP50 | p50 latency | ×realtime | steady RSS | |
|---|---|---|---|---|
| Diana | 0.7014 | 41 ms | 22.3× | 160 MiB |
| ultralytics-yolo26n-rect | 0.7014 | 45 ms | 19.4× | 310 MiB |
| ort-yolo26n | 0.6865 | 31 ms | 31.6× | 160 MiB |
mAP is identical to PyTorch to four decimals, at lower latency and half the memory. Model load is 68 ms.
Beyond the aggregate: across all ten tier/geometry configurations mAP matches PyTorch to within 0.08 pp on a 450-image holdout, and at n, m, l and x every detection is identical — same count, same classes, same order across 724 detections, boxes within 0.30 px.
The speed gate FAILS, and against ONNX Runtime rather than PyTorch: ORT is 31 ms to our 41. It is the fastest thing in the field and the gate compares against the fastest, which is the right rule. Diana is ahead of it on accuracy (0.7014 vs 0.6865) and level on memory.
The footprint gate passes by 1 MiB — 160 against ORT's 161. That is not a margin. mimalloc retains ~130 MiB of allocator churn against 26 MiB actually live, and the durable fix is upstream of the allocator.
Detection is single-image; video ingest is not wired. Only COCO's 80 classes are exercised.
n, s, m, l, x all run the same tier-agnostic graph. There is no
per-tier code path: depth, width and the c3k promotion (m/l/x build
their inner blocks as C3k, n/s do not) come from the checkpoint's own
scale rule, reproduced from Ultralytics' parse_model.
Two geometries: Rect reproduces Ultralytics' auto=True letterbox — the
smallest multiple-of-32 rectangle containing the scaled image — and Square
pads to imgsz × imgsz, matching the usual ONNX export. They are not
interchangeable and the flag is not cosmetic; it moves mAP.
Ultralytics' YOLO checkpoints are AGPL-3.0. This crate is MIT OR Apache-2.0 and ships no weights, vendors none, and redistributes none.
You bring your own .pt and convert it offline with tools/diana_convert.py,
an audited deterministic transcription into safetensors plus a manifest — no
retraining, no fine-tuning, no derivation. The AGPL obligations that attach to
the weights stay with the weights you obtained yourself.
The converter fails closed: a shape that does not match what the graph expects
is an error, never a silent partial load. That rule caught a real bug —
model.6.m.0.m.0.cv1 built as 32→16 where the checkpoint has 32→32 — on its
first run.
Diana does not drag in the rest of FFai. It has no dependency on
ffai-mercury, ffai-carmenta or ffai-argus; those are sibling crates, not
layers underneath.
| build | transitive crates | compiles C? |
|---|---|---|
ffai-diana, default |
138 | yes — onig_sys |
ffai-diana + ffai-models/fetch |
308 | yes — onig_sys, aws-lc-sys |
wasm32-unknown-unknown |
95 | no |
The 170-crate difference on native is the Hugging Face downloader —
reqwest, hyper, rustls, aws-lc-sys. Diana never calls it; its whole
use of ffai-models is load_dir, which reads TOML off disk. Off by default.
One C dependency remains on native and it is not ours. candle-core takes
tokenizers as a hard, non-optional dependency with features = ["onig"] — a
C regex engine, for text models Diana never touches, reached through one candle
module it never calls. tokenizers itself marks onig optional and ships a
pure-Rust alternative, so nothing technical requires this; it is one hardcoded
feature line upstream, and it cannot be gated from here.
It is build-time only: the output is an ordinary native binary with Oniguruma statically linked, no shared library to ship, no runtime dependency. It matters for musl/static builds, cross-compilation, minimal containers and no-C supply-chain policies, and nowhere else.
On wasm32 it disappears. candle declares that dependency as
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.tokenizers], so a
wasm build is 95 crates with no onig and no cc.
cargo check --target wasm32-unknown-unknown is clean — but compiling is not
deploying, and two runtime pieces are not done: weights load through
std::fs, which a browser does not have, and rayon compiles for wasm yet
needs atomics plus a threaded build to do anything.
The allocator is not inherited. The system allocator re-faults nearly every byte it hands back — 58,634 page faults per image — and costs 1.66×. A library cannot set a global allocator, so an embedder opts in itself:
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;That is a trade: it buys the 1.66× and costs roughly 120 MiB of retained pages, because retention is the mechanism.
The full campaign, every reverted experiment and every retracted number included: docs/whys/diana-latency.md.
MIT OR Apache-2.0. Model weights are not covered by it — YOLO26 checkpoints are AGPL-3.0 and you supply your own.