Skip to main content

edgefirst_tensor/
detect.rs

1// SPDX-FileCopyrightText: Copyright 2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared vocabulary for describing detections and masks.
5//!
6//! These types say *what a model reported about a buffer*, as plain data with
7//! no decoding and no drawing behaviour. They live here, beside
8//! [`PixelFormat`](crate::PixelFormat), [`Region`](crate::Region) and
9//! [`Colorimetry`](crate::Colorimetry), because `edgefirst-tensor` is this
10//! project's datatype crate.
11//!
12//! # Why here and not in `edgefirst-decoder`
13//!
14//! `edgefirst-image` needs this vocabulary to draw — `draw_decoded_masks` and
15//! `draw_proto_masks` take nothing else — but it has no business knowing how a
16//! YOLO or ModelPack head is decoded. Defining it in the decoder made a
17//! rendering crate depend on a model-postprocessing crate, which linked
18//! `serde_json`, `serde_yaml_ng`, `unsafe-libyaml`, `ndarray-stats`,
19//! `argminmax`, `rand`, `indexmap`, `hashbrown` and `itertools` into every
20//! image build: a YAML parser, because drawing a rectangle needed
21//! [`DetectBox`]. With the vocabulary here, `image` and `decoder` are siblings
22//! over a common base instead of a stack.
23//!
24//! Nothing in this module adds a dependency to `edgefirst-tensor`: every field
25//! is a primitive or a [`TensorDyn`].
26
27use crate::TensorDyn;
28
29/// A bounding box with f32 coordinates in XYXY format
30#[derive(Debug, Clone, Copy, PartialEq, Default)]
31#[repr(C)]
32pub struct BoundingBox {
33    /// left-most normalized coordinate of the bounding box
34    pub xmin: f32,
35    /// top-most normalized coordinate of the bounding box
36    pub ymin: f32,
37    /// right-most normalized coordinate of the bounding box
38    pub xmax: f32,
39    /// bottom-most normalized coordinate of the bounding box
40    pub ymax: f32,
41}
42
43impl BoundingBox {
44    /// Creates a new BoundingBox from the given coordinates
45    pub fn new(xmin: f32, ymin: f32, xmax: f32, ymax: f32) -> Self {
46        Self {
47            xmin,
48            ymin,
49            xmax,
50            ymax,
51        }
52    }
53
54    /// Transforms BoundingBox so that `xmin <= xmax` and `ymin <= ymax`
55    ///
56    /// ```
57    /// # use edgefirst_tensor::BoundingBox;
58    /// let bbox = BoundingBox::new(0.8, 0.6, 0.4, 0.2);
59    /// let canonical_bbox = bbox.to_canonical();
60    /// assert_eq!(canonical_bbox, BoundingBox::new(0.4, 0.2, 0.8, 0.6));
61    /// ```
62    pub fn to_canonical(&self) -> Self {
63        let xmin = self.xmin.min(self.xmax);
64        let xmax = self.xmin.max(self.xmax);
65        let ymin = self.ymin.min(self.ymax);
66        let ymax = self.ymin.max(self.ymax);
67        BoundingBox {
68            xmin,
69            ymin,
70            xmax,
71            ymax,
72        }
73    }
74}
75
76impl From<BoundingBox> for [f32; 4] {
77    /// Converts a BoundingBox into an array of 4 f32 values in xmin, ymin,
78    /// xmax, ymax order
79    /// # Examples
80    /// ```
81    /// # use edgefirst_tensor::BoundingBox;
82    /// let bbox = BoundingBox {
83    ///     xmin: 0.1,
84    ///     ymin: 0.2,
85    ///     xmax: 0.3,
86    ///     ymax: 0.4,
87    /// };
88    /// let arr: [f32; 4] = bbox.into();
89    /// assert_eq!(arr, [0.1, 0.2, 0.3, 0.4]);
90    /// ```
91    fn from(b: BoundingBox) -> Self {
92        [b.xmin, b.ymin, b.xmax, b.ymax]
93    }
94}
95
96impl From<[f32; 4]> for BoundingBox {
97    // Converts an array of 4 f32 values in xmin, ymin, xmax, ymax order into a
98    // BoundingBox
99    fn from(arr: [f32; 4]) -> Self {
100        BoundingBox {
101            xmin: arr[0],
102            ymin: arr[1],
103            xmax: arr[2],
104            ymax: arr[3],
105        }
106    }
107}
108
109/// A detection box with f32 bbox and score
110#[derive(Debug, Clone, Copy, PartialEq, Default)]
111pub struct DetectBox {
112    pub bbox: BoundingBox,
113    /// model-specific score for this detection, higher implies more confidence
114    pub score: f32,
115    /// label index for this detection
116    pub label: usize,
117}
118
119/// A segmentation result paired with the normalized bounding box that
120/// describes the spatial extent of `segmentation`.
121///
122/// `Segmentation` bounds are for rendering masks, and `DetectBox.bbox` for
123/// IoU evaluation, drawing detection rectangles, etc. The mask region always
124/// encloses the detection bbox.
125///
126/// The `xmin`/`ymin`/`xmax`/`ymax` fields describe the **mask region** — the
127/// proto-grid-aligned crop the `segmentation` tensor was sliced from. They are
128/// quantized to the proto grid step (`1/proto_height` and `1/proto_width`,
129/// typically `1/160`), so the region's origin floors and its extent ceils
130/// relative to the companion [`DetectBox`]'s `bbox`. The detection bbox itself
131/// stays un-snapped (see EDGEAI-1304).
132#[derive(Debug)]
133pub struct Segmentation {
134    /// Left-most normalized coordinate of the mask region (proto-grid
135    /// floor of the companion `DetectBox.bbox.xmin`).
136    pub xmin: f32,
137    /// Top-most normalized coordinate of the mask region (proto-grid
138    /// floor of the companion `DetectBox.bbox.ymin`).
139    pub ymin: f32,
140    /// Right-most normalized coordinate of the mask region (proto-grid
141    /// ceil of the companion `DetectBox.bbox.xmax`).
142    pub xmax: f32,
143    /// Bottom-most normalized coordinate of the mask region (proto-grid
144    /// ceil of the companion `DetectBox.bbox.ymax`).
145    pub ymax: f32,
146    /// Mask data, shape `[H, W, C]`, dtype `U8`.
147    ///
148    /// For instance segmentation (e.g. YOLO): `C=1` — per-instance mask with
149    /// continuous sigmoid confidence values quantized to u8 (0 = background,
150    /// 255 = full confidence). Renderers typically threshold at 128 (sigmoid
151    /// 0.5) or use smooth interpolation for anti-aliased edges.
152    ///
153    /// For semantic segmentation (e.g. ModelPack): `C=num_classes` — per-pixel
154    /// class scores where the object class is the argmax index.
155    ///
156    /// A [`TensorDyn`] rather than an `ndarray::Array3<u8>`, matching
157    /// [`ProtoData`]. That is what keeps this crate free of an `ndarray`
158    /// dependency; callers who want ndarray ergonomics already have
159    /// [`TensorMapTrait::view`](crate::TensorMapTrait::view) behind the
160    /// existing optional `ndarray` feature.
161    pub segmentation: TensorDyn,
162}
163
164/// Memory layout of the prototype tensor within [`ProtoData`].
165///
166/// Models may output protos in either channel-last (NHWC) or channel-first
167/// (NCHW) layout. The mask materialisation kernels dispatch on this field to
168/// avoid a costly per-frame transpose.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum ProtoLayout {
171    /// Channel-last: tensor shape is `[H, W, K]`, contiguous along K.
172    /// This is the traditional layout produced by the `extract_proto_data`
173    /// path after transposing from NCHW model outputs.
174    Nhwc,
175    /// Channel-first: tensor shape is `[K, H, W]`, each channel plane is
176    /// contiguous. Skipping the NCHW→NHWC transpose saves ~3 ms per frame
177    /// on Cortex-A53/A55 targets (819 KB for 32×160×160 protos).
178    Nchw,
179}
180
181/// Raw prototype data for fused decode+render pipelines.
182///
183/// Holds post-NMS intermediate state before mask materialization, allowing the
184/// renderer to compute `mask_coeff @ protos` directly (e.g. in a GPU fragment
185/// shader) without materializing intermediate masks.
186///
187/// Both fields are carried as [`TensorDyn`] so downstream consumers (Rust, C
188/// API, Python) get zero-copy typed access through the HAL's shared tensor
189/// infrastructure. Dtype policy:
190///
191/// | Source model | protos dtype | mask_coefficients dtype | protos.quantization |
192/// |---|---|---|---|
193/// | int8 quantized | [`TensorDyn::I8`] | [`TensorDyn::I8`] (raw + quantization) | `Some(q)` |
194/// | int16 quantized coefficients, int8 protos | [`TensorDyn::I8`] | [`TensorDyn::I16`] (raw + quantization) | `Some(q)` |
195/// | other integer protos (u8, i16, u16; dequantized) | [`TensorDyn::F32`] | per the coefficients' type: I8, I16 or F32 | `None` |
196/// | f32 | [`TensorDyn::F32`] | [`TensorDyn::F32`] | `None` |
197/// | f16 (TensorRT fp16) | [`TensorDyn::F16`] | [`TensorDyn::F16`] | `None` |
198/// | f64 (narrowed) | [`TensorDyn::F32`] | [`TensorDyn::F32`] | `None` |
199///
200/// Quantization metadata lives on the proto tensor itself via
201/// [`Tensor::quantization`](crate::Tensor::quantization) — float tensors
202/// cannot carry quantization (compile-time gated on the `IntegerType` sealed
203/// trait).
204///
205/// `TensorDyn` is not `Clone`, so neither is `ProtoData`. Consumers that need
206/// to share the proto buffer across threads should use `TensorDyn::clone_fd`
207/// / `dmabuf_clone` to dup the backing fd.
208#[derive(Debug)]
209pub struct ProtoData {
210    /// Per-detection mask coefficients, shape `[num_detections, num_protos]`.
211    pub mask_coefficients: TensorDyn,
212    /// Prototype tensor.
213    ///
214    /// - When `layout == ProtoLayout::Nhwc`: shape is `[proto_h, proto_w, num_protos]`.
215    /// - When `layout == ProtoLayout::Nchw`: shape is `[num_protos, proto_h, proto_w]`.
216    pub protos: TensorDyn,
217    /// Physical memory layout of the `protos` tensor.
218    pub layout: ProtoLayout,
219}
220
221/// Invert a letterbox: map a [`BoundingBox`] normalized over the model input
222/// back to normalized-over-the-crop, given the content bounds
223/// `[lx0, ly0, lx1, ly1]`. The box is canonicalised first, a degenerate
224/// (zero-span) letterbox axis maps with unit scale (no divide-by-zero), and the
225/// result is clamped to `[0, 1]`.
226///
227/// This is the single home for the inverse-letterbox transform; both
228/// `edgefirst_decoder::tiling`'s detection lift and `edgefirst_image`'s mask
229/// path call it, which is why it belongs beside the type it transforms rather
230/// than in either consumer.
231#[must_use]
232#[inline]
233pub fn unletter_norm(b: BoundingBox, lb: [f32; 4]) -> BoundingBox {
234    let b = b.to_canonical();
235    let [lx0, ly0, lx1, ly1] = lb;
236    let inv_w = if lx1 > lx0 { 1.0 / (lx1 - lx0) } else { 1.0 };
237    let inv_h = if ly1 > ly0 { 1.0 / (ly1 - ly0) } else { 1.0 };
238    BoundingBox {
239        xmin: ((b.xmin - lx0) * inv_w).clamp(0.0, 1.0),
240        ymin: ((b.ymin - ly0) * inv_h).clamp(0.0, 1.0),
241        xmax: ((b.xmax - lx0) * inv_w).clamp(0.0, 1.0),
242        ymax: ((b.ymax - ly0) * inv_h).clamp(0.0, 1.0),
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn bounding_box_is_c_compatible() {
252        // The C API exposes this layout; `#[repr(C)]` and the field order are
253        // load-bearing, not stylistic.
254        assert_eq!(std::mem::size_of::<BoundingBox>(), 16);
255        assert_eq!(std::mem::align_of::<BoundingBox>(), 4);
256    }
257
258    #[test]
259    fn bounding_box_round_trips_through_an_array() {
260        let bb = BoundingBox::new(0.1, 0.2, 0.3, 0.4);
261        let arr: [f32; 4] = bb.into();
262        assert_eq!(BoundingBox::from(arr), bb);
263    }
264
265    #[test]
266    fn unletter_norm_is_identity_for_a_full_frame_letterbox() {
267        let bb = BoundingBox::new(0.25, 0.25, 0.75, 0.75);
268        assert_eq!(unletter_norm(bb, [0.0, 0.0, 1.0, 1.0]), bb);
269    }
270
271    #[test]
272    fn unletter_norm_canonicalises_and_clamps() {
273        // Inverted input must come back canonical (xmin <= xmax) and inside
274        // [0,1] -- the contract `image`'s mask path relies on.
275        let got = unletter_norm(
276            BoundingBox::new(0.9, 0.9, 0.1, 0.1),
277            [0.0, 0.125, 1.0, 0.875],
278        );
279        assert!(got.xmin <= got.xmax && got.ymin <= got.ymax);
280        for v in [got.xmin, got.ymin, got.xmax, got.ymax] {
281            assert!((0.0..=1.0).contains(&v), "{v} escaped [0,1]");
282        }
283    }
284
285    #[test]
286    fn unletter_norm_survives_a_degenerate_letterbox_axis() {
287        // A zero-span axis must map with unit scale rather than dividing by
288        // zero and producing NaN/inf.
289        let got = unletter_norm(BoundingBox::new(0.2, 0.2, 0.8, 0.8), [0.5, 0.0, 0.5, 1.0]);
290        for v in [got.xmin, got.ymin, got.xmax, got.ymax] {
291            assert!(v.is_finite(), "degenerate axis produced {v}");
292        }
293    }
294
295    #[test]
296    fn unletter_norm_removes_offset_and_rescales_by_the_content_span() {
297        // Every operand is a short binary fraction, so the exact result is
298        // representable and the comparison can be exact.
299        let got = unletter_norm(
300            BoundingBox::new(0.375, 0.25, 0.625, 0.5),
301            [0.25, 0.125, 0.75, 0.625],
302        );
303        assert_eq!(got, BoundingBox::new(0.25, 0.25, 0.75, 0.75));
304    }
305
306    #[test]
307    fn unletter_norm_uses_independent_spans_per_axis() {
308        // x content spans 0.5 (scale 2), y content spans 0.25 (scale 4).
309        let got = unletter_norm(
310            BoundingBox::new(0.5, 0.5, 0.625, 0.5625),
311            [0.25, 0.375, 0.75, 0.625],
312        );
313        assert_eq!(got, BoundingBox::new(0.5, 0.5, 0.75, 0.75));
314    }
315
316    #[test]
317    fn unletter_norm_maps_a_zero_span_axis_with_unit_scale() {
318        let close = |a: f32, b: f32| (a - b).abs() < 1e-6;
319
320        // Both axes degenerate: only the offset is removed, then clamped.
321        let got = unletter_norm(BoundingBox::new(0.2, 0.2, 0.8, 0.8), [0.5, 0.5, 0.5, 0.5]);
322        assert_eq!(got.xmin, 0.0);
323        assert_eq!(got.ymin, 0.0);
324        assert!(close(got.xmax, 0.3), "xmax {}", got.xmax);
325        assert!(close(got.ymax, 0.3), "ymax {}", got.ymax);
326
327        // Only x degenerate: y still rescales by its span.
328        let got = unletter_norm(
329            BoundingBox::new(0.625, 0.375, 0.75, 0.625),
330            [0.5, 0.25, 0.5, 0.75],
331        );
332        assert_eq!(got, BoundingBox::new(0.125, 0.25, 0.25, 0.75));
333
334        // Only y degenerate: x still rescales by its span.
335        let got = unletter_norm(
336            BoundingBox::new(0.375, 0.625, 0.625, 0.75),
337            [0.25, 0.5, 0.75, 0.5],
338        );
339        assert_eq!(got, BoundingBox::new(0.25, 0.125, 0.75, 0.25));
340    }
341}