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}