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/// | f32 | [`TensorDyn::F32`] | [`TensorDyn::F32`] | `None` |
195/// | f16 (TensorRT fp16) | [`TensorDyn::F16`] | [`TensorDyn::F16`] | `None` |
196/// | f64 (narrowed) | [`TensorDyn::F32`] | [`TensorDyn::F32`] | `None` |
197///
198/// Quantization metadata lives on the proto tensor itself via
199/// [`Tensor::quantization`](crate::Tensor::quantization) — float tensors
200/// cannot carry quantization (compile-time gated on the `IntegerType` sealed
201/// trait).
202///
203/// `TensorDyn` is not `Clone`, so neither is `ProtoData`. Consumers that need
204/// to share the proto buffer across threads should use `TensorDyn::clone_fd`
205/// / `dmabuf_clone` to dup the backing fd.
206#[derive(Debug)]
207pub struct ProtoData {
208    /// Per-detection mask coefficients, shape `[num_detections, num_protos]`.
209    pub mask_coefficients: TensorDyn,
210    /// Prototype tensor.
211    ///
212    /// - When `layout == ProtoLayout::Nhwc`: shape is `[proto_h, proto_w, num_protos]`.
213    /// - When `layout == ProtoLayout::Nchw`: shape is `[num_protos, proto_h, proto_w]`.
214    pub protos: TensorDyn,
215    /// Physical memory layout of the `protos` tensor.
216    pub layout: ProtoLayout,
217}
218
219/// Invert a letterbox: map a [`BoundingBox`] normalized over the model input
220/// back to normalized-over-the-crop, given the content bounds
221/// `[lx0, ly0, lx1, ly1]`. The box is canonicalised first, a degenerate
222/// (zero-span) letterbox axis maps with unit scale (no divide-by-zero), and the
223/// result is clamped to `[0, 1]`.
224///
225/// This is the single home for the inverse-letterbox transform; both
226/// `edgefirst_decoder::tiling`'s detection lift and `edgefirst_image`'s mask
227/// path call it, which is why it belongs beside the type it transforms rather
228/// than in either consumer.
229#[must_use]
230#[inline]
231pub fn unletter_norm(b: BoundingBox, lb: [f32; 4]) -> BoundingBox {
232    let b = b.to_canonical();
233    let [lx0, ly0, lx1, ly1] = lb;
234    let inv_w = if lx1 > lx0 { 1.0 / (lx1 - lx0) } else { 1.0 };
235    let inv_h = if ly1 > ly0 { 1.0 / (ly1 - ly0) } else { 1.0 };
236    BoundingBox {
237        xmin: ((b.xmin - lx0) * inv_w).clamp(0.0, 1.0),
238        ymin: ((b.ymin - ly0) * inv_h).clamp(0.0, 1.0),
239        xmax: ((b.xmax - lx0) * inv_w).clamp(0.0, 1.0),
240        ymax: ((b.ymax - ly0) * inv_h).clamp(0.0, 1.0),
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn bounding_box_is_c_compatible() {
250        // The C API exposes this layout; `#[repr(C)]` and the field order are
251        // load-bearing, not stylistic.
252        assert_eq!(std::mem::size_of::<BoundingBox>(), 16);
253        assert_eq!(std::mem::align_of::<BoundingBox>(), 4);
254    }
255
256    #[test]
257    fn bounding_box_round_trips_through_an_array() {
258        let bb = BoundingBox::new(0.1, 0.2, 0.3, 0.4);
259        let arr: [f32; 4] = bb.into();
260        assert_eq!(BoundingBox::from(arr), bb);
261    }
262
263    #[test]
264    fn unletter_norm_is_identity_for_a_full_frame_letterbox() {
265        let bb = BoundingBox::new(0.25, 0.25, 0.75, 0.75);
266        assert_eq!(unletter_norm(bb, [0.0, 0.0, 1.0, 1.0]), bb);
267    }
268
269    #[test]
270    fn unletter_norm_canonicalises_and_clamps() {
271        // Inverted input must come back canonical (xmin <= xmax) and inside
272        // [0,1] -- the contract `image`'s mask path relies on.
273        let got = unletter_norm(
274            BoundingBox::new(0.9, 0.9, 0.1, 0.1),
275            [0.0, 0.125, 1.0, 0.875],
276        );
277        assert!(got.xmin <= got.xmax && got.ymin <= got.ymax);
278        for v in [got.xmin, got.ymin, got.xmax, got.ymax] {
279            assert!((0.0..=1.0).contains(&v), "{v} escaped [0,1]");
280        }
281    }
282
283    #[test]
284    fn unletter_norm_survives_a_degenerate_letterbox_axis() {
285        // A zero-span axis must map with unit scale rather than dividing by
286        // zero and producing NaN/inf.
287        let got = unletter_norm(BoundingBox::new(0.2, 0.2, 0.8, 0.8), [0.5, 0.0, 0.5, 1.0]);
288        for v in [got.xmin, got.ymin, got.xmax, got.ymax] {
289            assert!(v.is_finite(), "degenerate axis produced {v}");
290        }
291    }
292}