edgefirst-decoder
High-performance ML model output decoding for object detection and segmentation.
This crate provides efficient post-processing for YOLO and ModelPack model outputs, supporting both floating-point and quantized inference results.
Role in edgefirst-hal
edgefirst-decoder sits between the inference engine and the image-rendering
side of the EdgeFirst HAL workspace:
- Depends on
edgefirst-tensorfor reading model output buffers. - Optionally depends on
edgefirst-tracker(featuretracker) fordecode_tracked(). - Consumed by
edgefirst-image— itsDetectBox,Segmentation, and proto-data outputs feed thedraw_decoded_masks/draw_proto_masks/draw_masks_trackedrendering APIs. - Re-exported from
edgefirst-halasedgefirst_hal::decoder. - Bridged to C via
edgefirst-hal-capi(cbindgen-generated C ABI). - Bridged to Python via
crates/python(PyO3 binding over the Rust umbrella crate; does not go through the C ABI).
Supported Models
| Family | Detection | Segmentation | Formats |
|---|---|---|---|
| YOLO | YOLOv5, v8, v11, v26 | Instance seg | float32, int8, uint8 |
| ModelPack | SSD-style | Semantic seg | float32, int8, uint8 |
Features
- Quantized decoding - Direct int8/uint8 processing without dequantization overhead
- Configurable NMS - Class-agnostic or class-aware non-maximum suppression
- Batch processing - Efficient handling of batched model outputs
- Builder pattern - Flexible configuration with sensible defaults
Quick Start
use ;
use TensorDyn;
// Build the decoder once, from the model's config document.
let decoder = new
.with_score_threshold
.with_iou_threshold
.with_config_json_str // String
.build?;
// Then decode once per inference frame. `decode` dispatches to the
// quantized or float kernel from the tensor dtype — there is no separate
// entry point per dtype.
let mut detections: = Vecwith_capacity;
let mut masks: = Vecwith_capacity;
let outputs: = model_outputs.iter.collect;
decoder.decode?;
// Process results
for det in &detections
Both Vecs are cleared on entry, and their capacity is an allocation hint
rather than a cap — the detection count is bounded by max_det (default 300),
not by what you pre-allocated.
Note: every model decode kernel in the
yoloandmodelpackmodules is crate-private, soDecoderis the entire public decoding surface — there is no supported way to decode a model output without going through it. Thefloatandbytemodules do export reusable primitives (nms_float,nms_class_aware_int,iou_value,ios_value,box_area, and friends) if you need NMS or box geometry on your own candidates.
Configuration
Decoders can be configured via JSON/YAML matching the model's output specification:
NMS Modes
ClassAgnostic- Suppress overlapping boxes regardless of class (default)ClassAware- Only suppress boxes with the same class labelNone- Bypass NMS (for models with built-in NMS)
Pre-NMS Top-K: Validation vs Deployment
The decoder's pre_nms_top_k parameter caps how many score-passing candidates
enter NMS, bounding its O(N²) cost via an O(N) partial sort. The default of
300 is tuned for deployment — but it must be raised (or set to 0 for
no limit) for mAP evaluation.
Why it matters
| Scenario | score_threshold |
Anchors passing filter | Effect of pre_nms_top_k = 300 |
|---|---|---|---|
| Deployment | ≥ 0.25 | Tens | No effect — fewer candidates than the cap |
| COCO mAP eval | 0.001 | Thousands | Discards ~74 % of valid candidates before NMS |
With COCO's low threshold, most of the 8 400 YOLO anchors pass the score filter. The default top-K of 300 silently truncates the candidate pool, causing ~9 pp box mAP loss — a measurement artifact, not a model quality issue. The decoder math is correct in both cases.
Recommended settings
// Deployment (real-time inference)
let decoder = new
.with_config_json_str
.with_score_threshold
// pre_nms_top_k = 300 (default) — appropriate
.build?;
// COCO mAP evaluation
let decoder = new
.with_config_json_str
.with_score_threshold
.with_pre_nms_top_k // pass all anchors to NMS (or 0 = no limit)
.with_max_det // COCO detection cap applied post-NMS
.build?;
Performance trade-off
Post-processing latency scales with the number of candidates entering NMS.
At deployment thresholds (≥ 0.25), the candidate count is already small
regardless of the top-K setting, so raising it has negligible cost. At
validation thresholds (0.001), the increase is measurable — but necessary
for correct recall across the full precision-recall curve.
End-to-End Models (YOLO26)
YOLO26 models embed NMS directly in the model architecture (one-to-one matching heads), eliminating the need for external NMS post-processing.
Configure via the decoder_version field in the model config:
When decoder_version is "yolo26", the decoder:
- Bypasses NMS entirely (the
nmsconfig field is ignored) - Expects post-NMS output format:
[batch, N, 6+]where columns are[x1, y1, x2, y2, conf, class, ...] - Supports both detection-only and detection+segmentation variants
For non-end-to-end YOLO26 exports (end2end=false), use decoder_version: "yolov8" with explicit NMS configuration.
Non-End-to-End Mode
Models exported with end2end=false require external NMS, configurable via the nms field:
Proto Mask API
For segmentation models, decode_proto() returns the mask prototypes and
per-detection coefficients instead of materialized pixel masks. Prefer it when
the masks are headed for a GPU rendering pipeline (e.g.
ImageProcessor::draw_proto_masks()): the GPU evaluates
sigmoid(coeffs @ protos) per output pixel, so the CPU never pays for
full-resolution masks.
It returns Ok(None) for detection-only and ModelPack models, and
Ok(Some(ProtoData)) for YOLO segmentation models. Like decode, it picks the
quantized or float kernel from the tensor dtype.
// GPU rendering path: decode proto data, pass to GL for fused rendering
let mut detections: = Vecwith_capacity;
let proto_data = decoder.decode_proto?;
// Detection-only models return None — there is nothing to overlay.
if let Some = proto_data
Model Type Variants
The decoder automatically selects the appropriate model type from the
output schema, supporting YOLO (detection / segmentation, with or
without end-to-end NMS and split outputs) and ModelPack (detection,
segmentation, split variants). The full variant matrix, output-shape
disambiguation rules, the 2-way split format used by TFLite INT8
segmentation models, and the nc=28 edge case are documented in
ARCHITECTURE.md § Model-type selection.
Tiled Inference (SAHI)
When a frame is preprocessed into an overlapping tile grid (see
edgefirst-image's tiled preprocessing), each tile is decoded independently and
its detections are lifted back to full-frame coordinates and merged. A single
object split across a tile seam has low IoU but high IoS
(intersection-over-smaller), so the default merge metric is Ios with a 0.5
threshold via a GREEDYNMM pass — canonical SAHI postprocessing.
The input and output sides share one TilePlacement (produced by
ImageProcessor::plan_tiles/tile_into) describing how each tile was cut.
use ;
use ;
// The per-tile decoder is deliberately permissive: a fragment clipped at a
// seam scores low, and a high per-tile threshold would discard it before the
// merge could rebuild the object. Gate the final scores in MergeConfig instead.
let decoder = new
.with_config_json_str
.with_score_threshold
.with_nms
.build?;
// One accumulator per in-flight frame; collect tiles in any order.
let mut acc = new;
for in tiles
// Once every tile has arrived, merge and normalize to [0,1] for the tracker.
assert!;
let detections = acc.finalize_normalized; // full-frame, deduplicated
push_tile is idempotent per placement.index, so out-of-order and
at-least-once tile delivery both converge to the same result — the merge runs once
at finalize, never per push. This is the "collect after the final tile" contract
a pipelined runtime needs: plan_tiles sizes the ring up front, tile_one streams
tiles through inference, and is_complete()/remaining() fence the frame.
The free functions lift_tile_boxes and merge_tiled_detections expose the two
stages directly if you need to merge without the accumulator. MergeConfig tunes
the metric (Ios, default, or Iou), the match threshold (0.5),
class_agnostic (false), max_det (300), and a final score_threshold. That
last one defaults to 0.0 on purpose: per-tile decode is the real flood
control, and this is where you put the score gate once fragments have been
joined. The same machinery supports standard SAHI with Ultralytics YOLO models.
Merged boxes can be fed straight to the tracker (next section), which expects
normalized coordinates, hence finalize_normalized.
Note: IoS merge reconstructs the enclosing union of fragments and cannot recover an object larger than a single tile; add an optional full-frame downscaled pass (another
push_tileatorigin=(0,0),crop_size=frame_dims) for mixed-scale datasets.
Tracked Decoding
The tracker feature adds decode_tracked to integrate object tracking directly into the decode step. Each decoded detection box is matched to a persistent track and assigned a stable UUID for the lifetime of the track.
Enable the feature in Cargo.toml:
= { = "0.28", = ["tracker"] }
Usage
use ;
use Tracker; // re-exported from edgefirst-tracker
use ByteTrackBuilder;
let decoder = new
.with_score_threshold
.with_iou_threshold
.with_config_json_str
.build?;
let mut tracker = new
.track_high_conf
.track_update
.build;
let mut detections: = Vecwith_capacity;
let mut masks: = Vecwith_capacity;
let mut tracks: = Vecwith_capacity;
decoder.decode_tracked?;
// detections[i] and tracks[i] correspond to the same object
for in detections.iter.zip
TrackInfo Fields
| Field | Type | Description |
|---|---|---|
uuid |
Uuid |
Stable unique identifier for the track |
tracked_location |
[f32; 4] |
Kalman-smoothed location in XYXY format |
count |
i32 |
Number of times the track has been updated |
created |
u64 |
Timestamp when the track was first created |
last_updated |
u64 |
Timestamp of the most recent update |
What decode_tracked does to detections
decode_tracked does not simply append track metadata to the decoded boxes. It
rewrites detections to be the set of active tracks, which changes two
things worth planning around:
- Every
det.bboxis overwritten with that track's Kalman-smoothedtracked_location. Afterdecode_tracked,det.bboxandtrack.tracked_locationhold the same coordinates; the raw pre-smoothing box is not returned. Usedecodeif you need the unsmoothed detections. - Tracks still alive but unmatched this frame are included, so
detectionscan contain entries that no detection in this frame produced. They carry a coasting Kalman prediction, and on the segmentation path they have no mask.track.last_updated(older than the current timestamp) identifies them.
detections[i] and tracks[i] describe the same track in both cases, so the
zip above stays valid.
Documentation
- Architecture overview: ARCHITECTURE.md
- Testing guide: TESTING.md
- Full API reference: docs.rs/edgefirst-decoder
- Project README: ../../README.md
License
Licensed under the Apache License, Version 2.0. See LICENSE for details.