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
293 #[test]
294 fn unletter_norm_removes_offset_and_rescales_by_the_content_span() {
295 // Every operand is a short binary fraction, so the exact result is
296 // representable and the comparison can be exact.
297 let got = unletter_norm(
298 BoundingBox::new(0.375, 0.25, 0.625, 0.5),
299 [0.25, 0.125, 0.75, 0.625],
300 );
301 assert_eq!(got, BoundingBox::new(0.25, 0.25, 0.75, 0.75));
302 }
303
304 #[test]
305 fn unletter_norm_uses_independent_spans_per_axis() {
306 // x content spans 0.5 (scale 2), y content spans 0.25 (scale 4).
307 let got = unletter_norm(
308 BoundingBox::new(0.5, 0.5, 0.625, 0.5625),
309 [0.25, 0.375, 0.75, 0.625],
310 );
311 assert_eq!(got, BoundingBox::new(0.5, 0.5, 0.75, 0.75));
312 }
313
314 #[test]
315 fn unletter_norm_maps_a_zero_span_axis_with_unit_scale() {
316 let close = |a: f32, b: f32| (a - b).abs() < 1e-6;
317
318 // Both axes degenerate: only the offset is removed, then clamped.
319 let got = unletter_norm(BoundingBox::new(0.2, 0.2, 0.8, 0.8), [0.5, 0.5, 0.5, 0.5]);
320 assert_eq!(got.xmin, 0.0);
321 assert_eq!(got.ymin, 0.0);
322 assert!(close(got.xmax, 0.3), "xmax {}", got.xmax);
323 assert!(close(got.ymax, 0.3), "ymax {}", got.ymax);
324
325 // Only x degenerate: y still rescales by its span.
326 let got = unletter_norm(
327 BoundingBox::new(0.625, 0.375, 0.75, 0.625),
328 [0.5, 0.25, 0.5, 0.75],
329 );
330 assert_eq!(got, BoundingBox::new(0.125, 0.25, 0.25, 0.75));
331
332 // Only y degenerate: x still rescales by its span.
333 let got = unletter_norm(
334 BoundingBox::new(0.375, 0.625, 0.625, 0.75),
335 [0.25, 0.5, 0.75, 0.5],
336 );
337 assert_eq!(got, BoundingBox::new(0.25, 0.125, 0.75, 0.25));
338 }
339}