edgefirst-decoder 0.37.0

ML model output decoding for YOLO and ModelPack object detection and segmentation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
# edgefirst-decoder

[![Crates.io](https://img.shields.io/crates/v/edgefirst-decoder.svg)](https://crates.io/crates/edgefirst-decoder)
[![Documentation](https://docs.rs/edgefirst-decoder/badge.svg)](https://docs.rs/edgefirst-decoder)
[![License](https://img.shields.io/crates/l/edgefirst-decoder.svg)](LICENSE)

**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-tensor`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/) for reading model output buffers.
- Optionally depends on [`edgefirst-tracker`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tracker/) (feature `tracker`) for `decode_tracked()`.
- Consumed by [`edgefirst-image`](https://github.com/EdgeFirstAI/hal/blob/main/crates/image/) — its `DetectBox`, `Segmentation`, and proto-data outputs feed the `draw_decoded_masks` / `draw_proto_masks` / `draw_masks_tracked` rendering APIs.
- Bridged to C via [`edgefirst-decoder-capi`](https://github.com/EdgeFirstAI/hal/blob/main/crates/decoder-capi/) (`libedgefirst_decoder`, `edgefirst/decoder.h`).
- Bridged to Python via [`crates/python-decoder`](https://github.com/EdgeFirstAI/hal/blob/main/crates/python-decoder/).

## Supported Models

| Family | Detection | Segmentation | Formats |
|--------|-----------|--------------|---------|
| **YOLO** | YOLOv5, v8, v11, v26 | Instance seg | float32, int8, uint8 |
| **ModelPack** | SSD-style | Semantic seg | float32, int8, uint8 |
| **Semantic** (`decoder: semantic`) | — | Per-pixel scores or class maps (SegFormer, DeepLabV3, PIDNet, …) | float32, float16, int8, uint8, int16, uint16; class maps uint8, uint16, int32, int64 |

## 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

```rust,ignore
use edgefirst_decoder::{DecoderBuilder, DetectBox, Segmentation};
use edgefirst_tensor::TensorDyn;

// Build the decoder once, from the model's config document.
let decoder = DecoderBuilder::new()
    .with_score_threshold(0.25)
    .with_iou_threshold(0.7)
    .with_config_json_str(model_config_json)   // 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: Vec<DetectBox> = Vec::with_capacity(100);
let mut masks: Vec<Segmentation> = Vec::with_capacity(100);

let outputs: Vec<&TensorDyn> = model_outputs.iter().collect();
decoder.decode(&outputs, &mut detections, &mut masks)?;

// Process results
for det in &detections {
    println!("Class {} at [{:.1}, {:.1}, {:.1}, {:.1}] score={:.2}",
        det.label, det.bbox.xmin, det.bbox.ymin, det.bbox.xmax, det.bbox.ymax, det.score);
}
```

Both `Vec`s 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 `yolo` and `modelpack` modules is
> crate-private, so `Decoder` is the entire public decoding surface — there is
> no supported way to decode a model output without going through it. The
> `float` and `byte` modules 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:

```json
{
  "decoder": "ultralytics",
  "shape": [1, 84, 8400],
  "quantization": [0.012345, 26],
  "normalized": true
}
```

## NMS Modes

- `Auto` - Use the model config's `nms` mode, falling back to `ClassAware` (builder default)
- `ClassAware` - Only suppress boxes with the same class label (the default concrete mode, matching trainers and COCO evaluation)
- `ClassAgnostic` - Suppress overlapping boxes regardless of class
- `None` - Bypass NMS (for models with built-in NMS)

## Multi-Label Decode

By default the decoder keeps one box per anchor, labelled with its highest-scoring class. Multi-label decode instead emits a box for every class whose score clears `score_threshold`, which is how trainer validators and COCO-style evaluation count detections. Use it for validation and mAP runs, not deployment.

Enable it with `DecoderBuilder::with_multi_label(true)`, the Python `multi_label=True` constructor argument, or `ef_decoder_params_set_multi_label(p, 1)`. A model can also declare it with the optional top-level `nms_multi_label` key in `edgefirst.json`; an explicit API value overrides the key. Multi-label candidates go through the decoder's NMS mode: the class-aware default keeps every class of an anchor, while an explicit class-agnostic mode suppresses across classes, as Ultralytics does with `agnostic=True`.

`decode_tracked` and `decode_proto_tracked` ignore multi-label and always decode one label per box: the tracker matches on IoU only, so per-class duplicates of one anchor would become phantom tracks. Callers that feed their own tracker should use `decode_for_tracking`, which applies the same rule. Each logs a warning once per decoder if the decoder has multi-label enabled. A decoder whose multi-label comes from `nms_multi_label` in the model metadata also logs a warning when it is built, since the model file then changes what `decode` returns.

## 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; `0` means no
limit. It applies to every NMS decode path, detection-only models included,
and equal scores at the cut keep the lower anchor index. The default of
**300** is tuned for deployment — but it **must** be raised (or set to `0` for
no limit) for mAP evaluation. With multi-label decode on (from the API or the
model's `nms_multi_label`) and no explicit cap, the default is **30 000**,
Ultralytics' `max_nms`, since multi-label emits up to anchors × classes
candidates. `Decoder::pre_nms_top_k` is `None` until a cap is set; the
default is then resolved per decode (`Decoder::pre_nms_top_k_for`), so the
argmax tracking entry points of a multi-label decoder keep 300. A cap set
on the builder or assigned as `Some(n)` after build applies to every decode.

### 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

```rust,ignore
// Deployment (real-time inference)
let decoder = DecoderBuilder::new()
    .with_config_json_str(config)
    .with_score_threshold(0.25)
    // pre_nms_top_k = 300 (default) — appropriate
    .build()?;

// COCO mAP evaluation
let decoder = DecoderBuilder::new()
    .with_config_json_str(config)
    .with_score_threshold(0.001)
    .with_pre_nms_top_k(0)      // no limit: every candidate reaches NMS
    .with_max_det(300)           // 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:

```json
{
  "decoder": "ultralytics",
  "decoder_version": "yolo26",
  "shape": [1, 300, 6],
  "quantization": [0.012345, 26],
  "normalized": true
}
```

When `decoder_version` is `"yolo26"`, the decoder:
- Bypasses NMS entirely (the `nms` config 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:

```json
{
  "decoder": "ultralytics",
  "decoder_version": "yolov8",
  "nms": "class_agnostic",
  "shape": [1, 84, 8400]
}
```

## Semantic Segmentation API

`decode_semantic()` writes the class map of a semantic segmentation model into a tensor you allocate: `[H, W]`, u8 for up to 256 classes or u16 beyond, at any resolution (usually the original image's). Give it the content region of the model input (where the image sits inside the letterbox padding) so the padding is cropped, and pick the decode mode per call. `with_convert` takes the region from the options of the convert that prepared the input, so the decode uses exactly the geometry the convert painted.

```rust,ignore
use edgefirst_decoder::{schema::DecodeMode, semantic::SemanticOptions};

let mut class_map = TensorDyn::new(&[image_h, image_w], DType::U8, Some(TensorMemory::Mem), None)?;
let options = SemanticOptions::new()
    .with_mode(DecodeMode::Fast) // default: the model's decode_mode
    .with_convert(&convert_options, (image_w, image_h), (model_w, model_h))?;
decoder.decode_semantic(&outputs, &mut class_map, None, &options)?;
```

`DecodeMode::Accurate` upsamples the class scores bilinearly and takes the argmax, matching the PyTorch reference; `DecodeMode::Fast` takes the argmax at the model's resolution and upsamples the class map. On SegFormer-B1 (ADE20K) fast costs about 0.12 mIoU points. An optional confidence tensor receives the winning class's probability for overlays.

## Model Configuration Discovery

`model_config::resolve_model_config` finds what a model needs to be run and decoded — output schema, class labels, input preprocessing — from whatever the model carries, and records where each value came from.

```rust,ignore
use edgefirst_decoder::model_config::{resolve_model_config, ModelOverrides};
use edgefirst_decoder::{DecoderBuilder, ModelSignals};

// The model's tensors as the runtime reports them; None when unavailable.
let signals: Option<ModelSignals> = runtime_signals();
let mut overrides = ModelOverrides::default();
overrides.set("resize", "letterbox")?; // a command line's --resize, parsed once here
let config = resolve_model_config("segformer.onnx", signals, &overrides)?;
for line in config.describe() {
    tracing::info!("{line}"); // e.g. resize=letterbox-topleft [override]
}
let decoder = DecoderBuilder::new().with_schema(config.schema.clone()).build()?;
let spec = config.preprocessing; // edgefirst_tensor::PreprocessSpec
// edgefirst-image: let options = ConvertOptions::from_spec(&spec)?;
```

`describe()` prints `normalize`, `resize`, `task` and `decode` in the override grammar below, so a logged line can be passed back as an override. `preprocessing` carries the reference pipeline's interpolation; `edgefirst-image` runs bicubic and area as bilinear and the config's `notes` say so. `resolve_sources` takes already-read files: `ModelSources::new().with_embedded(..).with_sidecar(..).with_signals(..)`.

Sources, highest priority first; each fills only what the ones before it left unset, so `edgefirst.json` wins over a Hugging Face config field by field:

1. `edgefirst.json` embedded in the model (`input.preprocessing`, then the legacy `validation.normalization` / `validation.preprocessing`)
2. `edgefirst.json` and `labels.txt` beside the model
3. Hugging Face `config.json` + `preprocessor_config.json` embedded in the model
4. the same files beside the model (the `optimum` export layout)
5. Ultralytics export metadata (`infer_ultralytics_schema`)
6. a Hugging Face export recognised by its tensor names (`pixel_values` → `logits`), as guesses
7. the documented defaults: unsigned normalization, centred letterbox padded with 114, bilinear

`ModelOverrides` apply last. Their string grammar, shared with the Python and C APIs:

| Override | Values |
|---|---|
| `task` | `detect`, `segment`, `semantic` |
| `normalize` | `unsigned`, `signed`, `raw`, `imagenet`, `R,G,B/R,G,B` (mean/std, 0–1 domain, std positive) |
| `resize` | `stretch`, `letterbox`, `letterbox-center`, `letterbox-topleft` |
| `decode` | `accurate`, `fast` |
| `labels` | comma-separated non-empty class names in channel order, e.g. `background,person` |

Preset, placement and decode names are the shared vocabulary names (`NormalizationPreset`, `LetterboxPlacement`, `DecodeMode`). A bare `letterbox` follows the model family: Ultralytics centres with pixel pad 114; Hugging Face anchors top-left with an output pad of 0 (the mean colour); an `edgefirst.json` that declares a placement keeps it; anything else centres with pad 114. The explicit forms force the placement and keep the family's pad.

Embedding conventions:

| Container | Where | Names |
|---|---|---|
| ONNX | `ModelProto.metadata_props` | `edgefirst` (legacy `edgefirst.json`), `labels`, `hf_config`, `hf_preprocessor_config` |
| TFLite, HEF, DVM, TensorRT, any file | appended ZIP (TFLite associated files) | `edgefirst.json`, `labels.txt`, `config.json`, `preprocessor_config.json` |
| any | the model's directory | as for ZIP |

ONNX files are read by walking the top-level protobuf fields and seeking past the graph. ZIP trailers need the `model-files` feature (off by default; the C library and Python wheel enable it). A `config.json` counts as a Hugging Face config only with a string `model_type` and an `architectures` list or `transformers_version`; anything else is skipped and noted. Hugging Face `*ForSemanticSegmentation` architectures map to the semantic decoder; other architectures are refused with `UnsupportedArchitecture`. An `edgefirst.json` without outputs supplies preprocessing and labels but leaves the schema to a later source. Discovered labels whose count differs from a semantic model's classes are kept with a note; a `labels` override with the wrong count is an error. Sidecar files over 16 MiB and ZIP entries over 1 MiB are not read, and only the last 64 KiB of a model, its ZIP central directory and its text entries are read to find a trailer.

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

```rust,ignore
// GPU rendering path: decode proto data, pass to GL for fused rendering
let mut detections: Vec<DetectBox> = Vec::with_capacity(100);
let proto_data = decoder.decode_proto(&outputs, &mut detections)?;

// Detection-only models return None — there is nothing to overlay.
if let Some(proto_data) = proto_data {
    processor.draw_proto_masks(&mut frame, &detections, &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](https://github.com/EdgeFirstAI/hal/blob/main/crates/decoder/ARCHITECTURE.md#model-type-selection).

## Schema Inference for Ultralytics Exports

This crate decodes the *output tensors* of YOLO-family models. It contains
no upstream model code: the decoders, NMS and mask kernels here are an
independent Rust implementation, and nothing from any model-training
project is vendored, linked or redistributed. Exporting a model is
something you do with your own tooling; this crate reads what came out.

A vanilla Ultralytics YOLOv8/YOLO11/YOLO26 export (ONNX, TFLite, or CoreML;
detection or segmentation) carries no `edgefirst.json`, but its own
metadata and tensor shapes are enough to derive one.
`infer_ultralytics_schema` reads the
model's `names`/`task`/`end2end` metadata plus its I/O tensor shapes and
dtypes, and returns a `SchemaV2` ready to hand to `DecoderBuilder`, along
with the ordered class labels. Metadata and shapes are cross-checked, never
guessed past — a mismatch (e.g. a class count that doesn't fit the output
width) is a typed `InferError`, not a silent best-effort classification.

```rust,ignore
use edgefirst_decoder::{infer_ultralytics_schema, DecoderBuilder};

// `signals: ModelSignals` comes from the runtime that loaded the model.
let inferred = infer_ultralytics_schema(&signals)?;
let decoder = DecoderBuilder::new().with_schema(inferred.schema).build()?;
// `inferred.labels` are the class names in index order.
```

Rust callers hand the `SchemaV2` straight to `with_schema`; there is no
reason to serialize it. Nor does Python: its binding returns the schema as
a dict, which `Decoder(schema)` takes directly. The JSON rendering exists
for the C API alone, which has no dict to hand across the boundary.

The inferred schema pins the NMS *mode* but not the thresholds. Ultralytics runs NMS class-aware (`agnostic=False`), and the schema says so explicitly rather than relying on the builder's fallback. `with_nms` still overrides. YOLO26 end-to-end exports perform NMS in-graph and carry no mode.

Box normalization follows the export format, not a fixed convention:
Ultralytics ONNX exports report pixel-space boxes (`normalized: false`),
while TFLite exports report boxes normalized to `[0, 1]` (`normalized:
true`) — the inferred schema always matches the tensors the model actually
produces. A CoreML export (no-NMS) is pixel-space too, matching ONNX,
established by source-tracing the Ultralytics exporter and confirmed by the
same runtime measurement that pinned the other two. `ModelSource::Other` is refused rather
than defaulted, because this is the one field no tensor shape reveals and
picking wrong scales every box by the input size. The schema's
`decoder_version` (`yolov8` or `yolo26`) is always
set explicitly rather than left for the builder to infer from output shape,
because shape-based inference of end-to-end vs. pre-NMS layout is
ambiguous in the general case; inference here settles it once, from the
model's own `end2end` metadata flag when present.

## 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. The merge is a greedy, class-aware pass that by default keeps the
highest-scoring box of each matched group unchanged
(`MergeMode::KeepBest`); the enclosing-union merge of the original GREEDYNMM
reference is `MergeMode::Union`, opt-in.

The input and output sides share one `TilePlacement` (produced by
`ImageProcessor::plan_tiles`/`tile_into`) describing how each tile was cut.

```rust,ignore
use edgefirst_decoder::{DecoderBuilder, DetectBox, Nms, Segmentation};
use edgefirst_decoder::tiling::{MergeConfig, TiledFrameAccumulator};

// 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 = DecoderBuilder::new()
    .with_config_json_str(model_config_json)
    .with_score_threshold(0.05)
    .with_nms(Some(Nms::ClassAware))
    .build()?;

// One accumulator per in-flight frame; collect tiles in any order.
let mut acc = TiledFrameAccumulator::new(
    (frame_w as f32, frame_h as f32),
    placements.len(),   // tiles_total — the fan-in fence
    MergeConfig::default(),
    16,                 // estimated detections per tile (capacity hint)
);

for (tile_outputs, placement) in tiles {
    let mut boxes: Vec<DetectBox> = Vec::new();
    let mut masks: Vec<Segmentation> = Vec::new();
    decoder.decode(&tile_outputs, &mut boxes, &mut masks)?;
    // Lifts to full-frame pixels and appends. Returns false for a tile this
    // accumulator has already seen, which is how retries stay harmless.
    acc.push_tile(boxes, &placement);
}

// Once every tile has arrived, merge and normalize to [0,1] for the tracker.
assert!(acc.is_complete());
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), a final `score_threshold`, and the
`mode` (`KeepBest`, default, or `Union`). `score_threshold` 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:** `MergeMode::KeepBest` suppresses — the best box of each matched
> group comes back unchanged, never grown — so an object larger than a single tile
> is reported as its best fragment. `MergeMode::Union` grows that box to the
> group's *enclosing union* instead, but on the Ocean Cleanup ADIS 4K
> validation (TOP2-836) the union cost about 0.05 AP50 on every frame, tiled
> or not (whole frame: 0.491 AP50 with plain NMS, 0.437 after the union, 0.490
> with keep-best; 28-tile pipeline: 0.500 keep-best vs 0.442 union), which is
> why keep-best is the default. Add an optional full-frame downscaled pass
> (another `push_tile` at `origin=(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`:

```toml
edgefirst-decoder = { version = "0.30", features = ["tracker"] }
```

### Usage

```rust,ignore
use edgefirst_decoder::{DecoderBuilder, DetectBox, Segmentation, TrackInfo};
use edgefirst_decoder::Tracker; // re-exported from edgefirst-tracker
use edgefirst_tracker::ByteTrackBuilder;

let decoder = DecoderBuilder::new()
    .with_score_threshold(0.25)
    .with_iou_threshold(0.7)
    .with_config_json_str(model_config_json)
    .build()?;

let mut tracker = ByteTrackBuilder::new()
    .track_high_conf(0.5)
    .track_update(0.1)
    .build();

let mut detections: Vec<DetectBox> = Vec::with_capacity(100);
let mut masks: Vec<Segmentation> = Vec::with_capacity(100);
let mut tracks: Vec<TrackInfo> = Vec::with_capacity(100);

decoder.decode_tracked(
    &mut tracker,
    timestamp,          // u64 frame timestamp
    &model_outputs,     // &[&TensorDyn]
    &mut detections,
    &mut masks,
    &mut tracks,
)?;

// detections[i] and tracks[i] correspond to the same object
for (det, track) in detections.iter().zip(tracks.iter()) {
    println!(
        "Track {} class {} score={:.2} at [{:.1}, {:.1}, {:.1}, {:.1}]",
        track.uuid, det.label, det.score,
        det.bbox.xmin, det.bbox.ymin, det.bbox.xmax, det.bbox.ymax,
    );
}
```

### 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.bbox` is overwritten with that track's Kalman-smoothed
  `tracked_location`. After `decode_tracked`, `det.bbox` and
  `track.tracked_location` hold the same coordinates; the raw pre-smoothing box
  is not returned. Use `decode` if you need the unsmoothed detections.
- Tracks still alive but unmatched this frame are included, so `detections` can
  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](https://github.com/EdgeFirstAI/hal/blob/main/crates/decoder/ARCHITECTURE.md)
- Testing guide: [TESTING.md](https://github.com/EdgeFirstAI/hal/blob/main/crates/decoder/TESTING.md)
- Full API reference: [docs.rs/edgefirst-decoder](https://docs.rs/edgefirst-decoder)
- Project README: [../../README.md](https://github.com/EdgeFirstAI/hal/blob/main/README.md)

## License

Licensed under the Apache License, Version 2.0. See [LICENSE](https://github.com/EdgeFirstAI/hal/blob/main/LICENSE) for details.

<img referrerpolicy="no-referrer" src="https://px.edgefirst.ai/a.png?x-pxid=37aa7de2-6935-484e-b31d-9fd9a57fcd63" alt="" width="1" height="1" style="position:absolute; width:1px; height:1px; opacity:0; pointer-events:none;" />