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.7"
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 | steady RSS | |
|---|---|---|---|
| Diana (rect) | 0.7014 | ~41 ms | 121 MiB |
| ultralytics-yolo26n-rect | 0.7014 | ~40 ms | 310 MiB |
| ort-yolo26n (square only) | 0.6865 | 28 ms | 163 MiB |
mAP is identical to PyTorch to four decimals.
On speed, measure the CPU, not the wall. At work parity (518 detections against 522) Diana spends 84.6 ms of CPU per frame against Ultralytics' 314.2 — 3.71× less — because Diana is a ~2.4-core workload and Ultralytics a ~7.9-core one. What the wall clock says therefore depends on who owns the machine:
| conditions | Diana vs Ultralytics |
|---|---|
| separate processes, ABBA, CPU-timed | 1.147× wall, 3.71× less CPU |
| each engine pinned to its own 12 cores | 1.30× |
| 16 competing CPU threads | 1.92× |
| unpinned, sharing a machine | 0.79× — the reading that misleads |
Quiet-to-loaded degradation is 2.0× for Diana against 4.5×. Earlier versions of this page reported a single wall ratio — first "lower latency" from one favourable run, then "rough parity, median 1.11× over seven paired runs". Both were the bottom row of that table. If you are deploying to a laptop, an edge box, or a server already running something, the rows above it are the ones that apply.
Memory is the unambiguous win: 0.4× Ultralytics, 0.75× ONNX Runtime. 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 against ONNX Runtime by 2.89× at matched square geometry — 81 ms against 28 ms. ORT has no rect export, so comparing our rect against its square puts our REDUCED-work configuration against its full-work one; rect is 70–75 % of square's pixels on this corpus, and the 1.25× that mismatch produces is not like-for-like. Diana is ahead of ORT on accuracy (0.7014 rect vs 0.6865).
Against Ultralytics at matched geometry, 1.47× behind at square. At rect see the CPU table above — the wall ratio there depends on who owns the machine, and this page has twice quoted the wrong row of it.
Footprint is the unambiguous win — 121 MiB against ORT's 163 and Ultralytics' 310, the gate passing at 0.75×.
Only COCO's 80 classes are exercised.
The same backbone and neck, a different head. yolo26{n,s,m,l,x}-depth.pt
converted the same audited way, giving a dense map in metres.
use ffai_core::engine::{DepthEngine, DepthOptions};
use ffai_diana::{depth_engine::Yolo26Depth, image::Geometry};
let engine = Yolo26Depth::build("n", Geometry::Rect, "models");
let out = engine.depth(&image, &DepthOptions::default())?;
let (near, far) = out.range().unwrap();
println!("{}x{} map, {near:.2}-{far:.2} m", out.width, out.height);ffai depth -i street.jpg -o depth.bin # raw f32 metres, row-major
ffai depth -i street.jpg -o depth.png # 16-bit grayscale, normalised
Output is (1, H/4, W/4), unbounded — exp of a clamped logit rather than a
scaled sigmoid, which is what lets one model span an indoor corridor and an
outdoor street. --full-res maps it back onto the source image.
Gated at all five tiers against Ultralytics, per-pixel, through the engine rather than the bare graph:
| tier | worst relative error | range on the fixture |
|---|---|---|
| n | 4.05e-6 | 1.48–14.11 m |
| s | 4.46e-6 | 1.08–16.90 m |
| m | 7.40e-6 | 1.35–23.51 m |
| l | 5.54e-6 | 1.47–25.79 m |
| x | 6.19e-6 | 1.45–31.83 m |
No ffai bench depth line yet, so no speed or memory claim for depth —
only this correctness one, which is the stronger statement anyway: a
ground-truth metric would grade Ultralytics' weights, while this grades the
port.
ffai detect --live -i frames/ # a directory, sorted by name
A frame whose pixels have not moved reuses the previous detections at zero model cost — which also makes it an output stabiliser, since nothing re-rolls the model on a static scene.
| sequence | model runs | skip rate | throughput |
|---|---|---|---|
| fixed camera, ±2 sensor noise | 1 of 24 | 95.8 % | 47.1 fps |
| 1 px pan per frame | 24 of 24 | 0 % | 3.5 fps |
This is for fixed cameras — surveillance, fixed mounts, screen capture. A one-pixel shift already changes 63 % of the frame, so on handheld video it gates nothing and costs 0.2 % for the privilege. The signal is a changed-pixel FRACTION above a per-pixel delta noise cannot reach, not a mean difference: harvested on this corpus, ±6 levels of noise moved 0.000000 % of pixels past the delta while a one-pixel shift moved 63 %.
AP50 cannot see a tracker — swap every id in a sequence and it does not move a point. Scored on the metrics that can, against Ultralytics' own ByteTrack with the same weights, frames, confidence and class filter, and one scorer, over all seven MOT17 training sequences (5,316 frames):
| IDF1 | MOTA | ID switches | |
|---|---|---|---|
| Diana | 35.93 % | 24.91 % | 218 |
| Ultralytics ByteTrack | 36.92 % | 27.38 % | 800 |
Behind by 0.98 pp of IDF1, ahead on three of seven sequences and four of seven on MOTA, with a quarter of the ID switches — the number a deployment feels, since every switch is an identity handed to the wrong object downstream.
No appearance model, no ReID network, no second weight file: IoU, a Kalman filter and a rectangular Hungarian solve. Tracking adds no download and no licence.
use ffai_diana::track::{ByteTrack, TrackerConfig};
let mut tracker = ByteTrack::new(TrackerConfig::default());
// per frame, from `found.detections`:
// for t in tracker.update(&boxes, &scores, &classes) { /* t.id is stable */ }Pass --classes 0 when scoring MOT17. Diana is an 80-class COCO detector
and MOT17 ground truth is pedestrians only; without the filter, cars and buses
are scored as predicted pedestrians. That was 13.8–47.3 % of detections
depending on the sequence — worth 1.54 pp IDF1 and 5.38 pp MOTA — and every
run of ours before 2026-08-06 got it wrong. A NEGATIVE MOTA sat in the results
for weeks before anyone chased it; below zero means more errors than there are
ground-truth boxes, which is not a hard sequence but a broken comparison.
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.
And it now runs there. This page previously said compiling was not
deploying, and named two blockers: weights loading through std::fs, and
rayon needing threads wasm does not have. Both are resolved.
ffai-wasm is on crates.io: weights come
in as bytes through Yolo26::from_bytes, and rayon is not a dependency of
the wasm build at all — a serial shim supplies the same iterators, and
dropping rayon entirely is what proves the shim complete, since a missed call
site then fails to compile rather than panicking in a browser.
Verified against native on the same image, yolo26n, rect, conf 0.25:
| native | wasm | |
|---|---|---|
| detections | 13 | 13 |
| class mismatches | — | 0 |
| largest box/confidence difference | — | 0.000000 |
1.82 MB module, 18 ms model load, 257 ms detect single-threaded against ~34 ms native with a thread pool. Most of that gap is threads — and serial is the arm that was already winning on CPU: the intra-image fan-out measures 363 ms/image at one thread against 844 at twenty-four.
No ONNX Runtime Web, no TensorFlow.js, no JavaScript inference engine — the same Rust that runs natively runs in the tab.
The allocator is not inherited, and it is pure Rust now. The system allocator re-faults nearly every byte it hands back — 58,634 page faults per image — and costs 1.64×, the largest single effect in this campaign. A library cannot set a global allocator, so an embedder opts in itself:
#[global_allocator]
static GLOBAL: rusty_alloc_api::RustyAlloc = rusty_alloc_api::RustyAlloc;That used to be mimalloc, a C library.
rusty_alloc is the pure-Rust remake
of the same design, and it was measured before switching, not after:
byte-identical detections on every workload, parity on speed (49 paired
rounds, z = +2.43, against a null arm at z = -0.71), and a peak-RSS median
below mimalloc's. Retention is still the mechanism, so the trade is memory
for the 1.64×; it is now a trade you make without linking C.
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.