Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 

Repository files navigation

Diana

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"

What it does well, measured

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.

What it does not do yet

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.


Depth: metric distance per pixel

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.

LIVE: skip frames that did not change

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 %.

Tracking: identity, not just boxes

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.


Five tiers from one graph

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.

The weights are AGPL, and that is why they are not here

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.


Embedding it

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.

License

MIT OR Apache-2.0. Model weights are not covered by it — YOLO26 checkpoints are AGPL-3.0 and you supply your own.

About

Object detection in pure Rust: YOLO26 on candle from real Ultralytics. Identical to PyTorch at 3.71x less CPU and a third the memory. ByteTrack tracking and a WebAssembly build. No Python, ONNX, or vendored weights. The ffai-diana crate.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors