Skip to main content

edgefirst_decoder/
modelpack.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Decode kernels for the Au-Zone ModelPack output format.
5//!
6//! ModelPack emits SSD-style detection (separate box and score tensors) and
7//! semantic segmentation, in flat and per-layer split variants. Every kernel
8//! here is crate-private and reached through [`crate::Decoder`], which picks
9//! the variant from the output shapes at builder time.
10
11use ndarray::{Array2, ArrayView2, ArrayView3};
12use num_traits::{AsPrimitive, Float, PrimInt};
13
14use crate::{
15    byte::{postprocess_boxes_multilabel_quant, postprocess_boxes_quant, quantize_score_threshold},
16    configs::{Detection, Nms},
17    dequant_detect_box,
18    float::{postprocess_boxes_float, postprocess_boxes_multilabel_float},
19    yolo::nms_and_cap,
20    BBoxTypeTrait, DecoderError, DecoderResult, DetectBox, Quantization, XYWH, XYXY,
21};
22
23/// Configuration for ModelPack split detection decoder. The quantization is
24/// ignored when decoding float models.
25#[derive(Debug, Clone, PartialEq)]
26pub(crate) struct ModelPackDetectionConfig {
27    pub(crate) anchors: Vec<[f32; 2]>,
28    pub(crate) quantization: Option<Quantization>,
29}
30
31impl TryFrom<&Detection> for ModelPackDetectionConfig {
32    type Error = DecoderError;
33
34    fn try_from(value: &Detection) -> Result<Self, DecoderError> {
35        Ok(Self {
36            anchors: value.anchors.clone().ok_or_else(|| {
37                DecoderError::InvalidConfig("ModelPack Split Detection missing anchors".to_string())
38            })?,
39            quantization: value.quantization.map(Quantization::from),
40        })
41    }
42}
43
44/// Decodes ModelPack detection outputs from quantized tensors.
45///
46/// The boxes are expected to be in XYXY format.
47///
48/// Expected shapes of inputs:
49/// - boxes: (num_boxes, 4)
50/// - scores: (num_boxes, num_classes)
51///
52/// # Panics
53/// Panics if shapes don't match the expected dimensions (argmax path).
54///
55/// # Errors
56/// Returns [`DecoderError::InvalidShape`]
57/// when boxes are not `[N, 4]` or scores do not have one row per box.
58#[allow(clippy::too_many_arguments)]
59pub(crate) fn decode_modelpack_det<
60    BOX: PrimInt + AsPrimitive<f32> + Send + Sync,
61    SCORE: PrimInt + AsPrimitive<f32> + Send + Sync,
62>(
63    boxes_tensor: (ArrayView2<BOX>, Quantization),
64    scores_tensor: (ArrayView2<SCORE>, Quantization),
65    score_threshold: f32,
66    iou_threshold: f32,
67    nms: Option<Nms>,
68    pre_nms_top_k: usize,
69    max_det: usize,
70    multi_label: bool,
71    output_boxes: &mut Vec<DetectBox>,
72) -> DecoderResult<()>
73where
74    f32: AsPrimitive<SCORE>,
75{
76    impl_modelpack_quant::<XYXY, _, _>(
77        boxes_tensor,
78        scores_tensor,
79        score_threshold,
80        iou_threshold,
81        nms,
82        pre_nms_top_k,
83        max_det,
84        multi_label,
85        output_boxes,
86    )
87}
88
89/// Decodes ModelPack detection outputs from float tensors. The boxes
90/// are expected to be in XYXY format.
91///
92/// Expected shapes of inputs:
93/// - boxes: (num_boxes, 4)
94/// - scores: (num_boxes, num_classes)
95///
96/// # Panics
97/// Panics if shapes don't match the expected dimensions (argmax path).
98///
99/// # Errors
100/// Returns [`DecoderError::InvalidShape`]
101/// when boxes are not `[N, 4]` or scores do not have one row per box.
102#[allow(clippy::too_many_arguments)]
103pub(crate) fn decode_modelpack_float<
104    BOX: Float + AsPrimitive<f32> + Send + Sync,
105    SCORE: Float + AsPrimitive<f32> + Send + Sync,
106>(
107    boxes_tensor: ArrayView2<BOX>,
108    scores_tensor: ArrayView2<SCORE>,
109    score_threshold: f32,
110    iou_threshold: f32,
111    nms: Option<Nms>,
112    pre_nms_top_k: usize,
113    max_det: usize,
114    multi_label: bool,
115    output_boxes: &mut Vec<DetectBox>,
116) -> DecoderResult<()>
117where
118    f32: AsPrimitive<SCORE>,
119{
120    impl_modelpack_float::<XYXY, _, _>(
121        boxes_tensor,
122        scores_tensor,
123        score_threshold,
124        iou_threshold,
125        nms,
126        pre_nms_top_k,
127        max_det,
128        multi_label,
129        output_boxes,
130    )
131}
132
133/// Decodes ModelPack split detection outputs from quantized tensors. The boxes
134/// are expected to be in XYWH format.
135///
136/// The `configs` must correspond to the `outputs` in order.
137///
138/// Expected shapes of inputs:
139/// - outputs: (width, height, num_anchors * (5 + num_classes))
140///
141/// # Panics
142/// Panics if shapes don't match the expected dimensions.
143///
144/// # Errors
145/// Returns [`DecoderError::InvalidShape`]
146/// when boxes are not `[N, 4]` or scores do not have one row per box.
147#[cfg(test)]
148#[allow(clippy::too_many_arguments)]
149pub(crate) fn decode_modelpack_split_quant<D: AsPrimitive<f32>>(
150    outputs: &[ArrayView3<D>],
151    configs: &[ModelPackDetectionConfig],
152    score_threshold: f32,
153    iou_threshold: f32,
154    nms: Option<Nms>,
155    pre_nms_top_k: usize,
156    max_det: usize,
157    multi_label: bool,
158    output_boxes: &mut Vec<DetectBox>,
159) -> DecoderResult<()> {
160    impl_modelpack_split_quant::<XYWH, D>(
161        outputs,
162        configs,
163        score_threshold,
164        iou_threshold,
165        nms,
166        pre_nms_top_k,
167        max_det,
168        multi_label,
169        output_boxes,
170    )
171}
172
173/// Decodes ModelPack split detection outputs from float tensors. The boxes
174/// are expected to be in XYWH format.
175///
176/// The `configs` must correspond to the `outputs` in order.
177///
178/// Expected shapes of inputs:
179/// - outputs: (width, height, num_anchors * (5 + num_classes))
180///
181/// # Panics
182/// Panics if shapes don't match the expected dimensions.
183///
184/// # Errors
185/// Returns [`DecoderError::InvalidShape`]
186/// when boxes are not `[N, 4]` or scores do not have one row per box.
187#[allow(clippy::too_many_arguments)]
188pub(crate) fn decode_modelpack_split_float<D: AsPrimitive<f32>>(
189    outputs: &[ArrayView3<D>],
190    configs: &[ModelPackDetectionConfig],
191    score_threshold: f32,
192    iou_threshold: f32,
193    nms: Option<Nms>,
194    pre_nms_top_k: usize,
195    max_det: usize,
196    multi_label: bool,
197    output_boxes: &mut Vec<DetectBox>,
198) -> DecoderResult<()> {
199    impl_modelpack_split_float::<XYWH, D>(
200        outputs,
201        configs,
202        score_threshold,
203        iou_threshold,
204        nms,
205        pre_nms_top_k,
206        max_det,
207        multi_label,
208        output_boxes,
209    )
210}
211/// Implementation of ModelPack detection decoding for quantized tensors.
212///
213/// Expected shapes of inputs:
214/// - boxes: (num_boxes, 4)
215/// - scores: (num_boxes, num_classes)
216///
217/// # Panics
218/// Panics if shapes don't match the expected dimensions (argmax path).
219///
220/// # Errors
221/// Returns [`DecoderError::InvalidShape`]
222/// when boxes are not `[N, 4]` or scores do not have one row per box.
223#[allow(clippy::too_many_arguments)]
224pub(crate) fn impl_modelpack_quant<
225    B: BBoxTypeTrait,
226    BOX: PrimInt + AsPrimitive<f32> + Send + Sync,
227    SCORE: PrimInt + AsPrimitive<f32> + Send + Sync,
228>(
229    boxes: (ArrayView2<BOX>, Quantization),
230    scores: (ArrayView2<SCORE>, Quantization),
231    score_threshold: f32,
232    iou_threshold: f32,
233    nms: Option<Nms>,
234    pre_nms_top_k: usize,
235    max_det: usize,
236    multi_label: bool,
237    output_boxes: &mut Vec<DetectBox>,
238) -> DecoderResult<()>
239where
240    f32: AsPrimitive<SCORE>,
241{
242    let (boxes_tensor, quant_boxes) = boxes;
243    let (scores_tensor, quant_scores) = scores;
244    let boxes = {
245        let score_threshold = quantize_score_threshold(score_threshold, quant_scores);
246        if multi_label {
247            postprocess_boxes_multilabel_quant::<B, _, _>(
248                score_threshold,
249                boxes_tensor,
250                scores_tensor,
251                quant_boxes,
252            )?
253        } else {
254            postprocess_boxes_quant::<B, _, _>(
255                score_threshold,
256                boxes_tensor,
257                scores_tensor,
258                quant_boxes,
259            )
260        }
261    };
262    let boxes = nms_and_cap(boxes, nms, iou_threshold, pre_nms_top_k, max_det);
263    output_boxes.clear();
264    output_boxes.extend(boxes.iter().map(|b| dequant_detect_box(b, quant_scores)));
265    Ok(())
266}
267
268/// Implementation of ModelPack detection decoding for float tensors.
269///
270/// Expected shapes of inputs:
271/// - boxes: (num_boxes, 4)
272/// - scores: (num_boxes, num_classes)
273///
274/// # Panics
275/// Panics if shapes don't match the expected dimensions (argmax path).
276///
277/// # Errors
278/// Returns [`DecoderError::InvalidShape`]
279/// when boxes are not `[N, 4]` or scores do not have one row per box.
280#[allow(clippy::too_many_arguments)]
281pub(crate) fn impl_modelpack_float<
282    B: BBoxTypeTrait,
283    BOX: Float + AsPrimitive<f32> + Send + Sync,
284    SCORE: Float + AsPrimitive<f32> + Send + Sync,
285>(
286    boxes_tensor: ArrayView2<BOX>,
287    scores_tensor: ArrayView2<SCORE>,
288    score_threshold: f32,
289    iou_threshold: f32,
290    nms: Option<Nms>,
291    pre_nms_top_k: usize,
292    max_det: usize,
293    multi_label: bool,
294    output_boxes: &mut Vec<DetectBox>,
295) -> DecoderResult<()>
296where
297    f32: AsPrimitive<SCORE>,
298{
299    let boxes = if multi_label {
300        postprocess_boxes_multilabel_float::<B, _, _>(
301            score_threshold.as_(),
302            boxes_tensor,
303            scores_tensor,
304        )?
305    } else {
306        postprocess_boxes_float::<B, _, _>(score_threshold.as_(), boxes_tensor, scores_tensor)
307    };
308    output_boxes.clear();
309    output_boxes.extend(nms_and_cap(
310        boxes,
311        nms,
312        iou_threshold,
313        pre_nms_top_k,
314        max_det,
315    ));
316    Ok(())
317}
318
319/// Implementation of ModelPack split detection decoding for quantized tensors.
320///
321/// Expected shapes of inputs:
322/// - boxes: (num_boxes, 4)
323/// - scores: (num_boxes, num_classes)
324///
325/// # Panics
326/// Panics if shapes don't match the expected dimensions.
327///
328/// # Errors
329/// Returns [`DecoderError::InvalidShape`]
330/// when boxes are not `[N, 4]` or scores do not have one row per box.
331#[cfg(test)]
332#[allow(clippy::too_many_arguments)]
333pub(crate) fn impl_modelpack_split_quant<B: BBoxTypeTrait, D: AsPrimitive<f32>>(
334    outputs: &[ArrayView3<D>],
335    configs: &[ModelPackDetectionConfig],
336    score_threshold: f32,
337    iou_threshold: f32,
338    nms: Option<Nms>,
339    pre_nms_top_k: usize,
340    max_det: usize,
341    multi_label: bool,
342    output_boxes: &mut Vec<DetectBox>,
343) -> DecoderResult<()> {
344    let (boxes_tensor, scores_tensor) = postprocess_modelpack_split_quant(outputs, configs);
345    let boxes = if multi_label {
346        postprocess_boxes_multilabel_float::<B, _, _>(
347            score_threshold,
348            boxes_tensor.view(),
349            scores_tensor.view(),
350        )?
351    } else {
352        postprocess_boxes_float::<B, _, _>(
353            score_threshold,
354            boxes_tensor.view(),
355            scores_tensor.view(),
356        )
357    };
358    output_boxes.clear();
359    output_boxes.extend(nms_and_cap(
360        boxes,
361        nms,
362        iou_threshold,
363        pre_nms_top_k,
364        max_det,
365    ));
366    Ok(())
367}
368
369/// Implementation of ModelPack split detection decoding for float tensors.
370///
371/// The `configs` must correspond to the `outputs` in order.
372///
373/// Expected shapes of inputs:
374/// - outputs: (width, height, num_anchors * (5 + num_classes))
375///
376/// # Panics
377/// Panics if shapes don't match the expected dimensions.
378///
379/// # Errors
380/// Returns [`DecoderError::InvalidShape`]
381/// when boxes are not `[N, 4]` or scores do not have one row per box.
382#[allow(clippy::too_many_arguments)]
383pub(crate) fn impl_modelpack_split_float<B: BBoxTypeTrait, D: AsPrimitive<f32>>(
384    outputs: &[ArrayView3<D>],
385    configs: &[ModelPackDetectionConfig],
386    score_threshold: f32,
387    iou_threshold: f32,
388    nms: Option<Nms>,
389    pre_nms_top_k: usize,
390    max_det: usize,
391    multi_label: bool,
392    output_boxes: &mut Vec<DetectBox>,
393) -> DecoderResult<()> {
394    let (boxes_tensor, scores_tensor) = postprocess_modelpack_split_float(outputs, configs);
395    let boxes = if multi_label {
396        postprocess_boxes_multilabel_float::<B, _, _>(
397            score_threshold,
398            boxes_tensor.view(),
399            scores_tensor.view(),
400        )?
401    } else {
402        postprocess_boxes_float::<B, _, _>(
403            score_threshold,
404            boxes_tensor.view(),
405            scores_tensor.view(),
406        )
407    };
408    output_boxes.clear();
409    output_boxes.extend(nms_and_cap(
410        boxes,
411        nms,
412        iou_threshold,
413        pre_nms_top_k,
414        max_det,
415    ));
416    Ok(())
417}
418
419/// Post processes ModelPack split detection into detection boxes,
420/// filtering out any boxes below the score threshold. Returns the boxes and
421/// scores tensors. Boxes are in XYWH format.
422#[cfg(test)]
423pub(crate) fn postprocess_modelpack_split_quant<T: AsPrimitive<f32>>(
424    outputs: &[ArrayView3<T>],
425    config: &[ModelPackDetectionConfig],
426) -> (Array2<f32>, Array2<f32>) {
427    let mut total_capacity = 0;
428    let mut nc = 0;
429    for (p, detail) in outputs.iter().zip(config) {
430        let shape = p.shape();
431        let na = detail.anchors.len();
432        nc = *shape
433            .last()
434            .expect("Shape must have at least one dimension")
435            / na
436            - 5;
437        total_capacity += shape[0] * shape[1] * na;
438    }
439    let mut bboxes = Vec::with_capacity(total_capacity * 4);
440    let mut bscores = Vec::with_capacity(total_capacity * nc);
441
442    for (p, detail) in outputs.iter().zip(config) {
443        let anchors = &detail.anchors;
444        let na = detail.anchors.len();
445        let shape = p.shape();
446        assert_eq!(
447            shape.iter().product::<usize>(),
448            p.len(),
449            "Shape product doesn't match tensor length"
450        );
451        let p_sigmoid = if let Some(quant) = &detail.quantization {
452            let scaled_zero = -quant.zero_point as f32 * quant.scale;
453            p.mapv(|x| fast_sigmoid_impl(x.as_() * quant.scale + scaled_zero))
454        } else {
455            p.mapv(|x| fast_sigmoid_impl(x.as_()))
456        };
457        let p_sigmoid = p_sigmoid.as_standard_layout();
458
459        // Safe to unwrap since we ensured standard layout above
460        let p = p_sigmoid
461            .as_slice()
462            .expect("Sigmoids are not in standard layout");
463        let height = shape[0];
464        let width = shape[1];
465
466        let div_width = 1.0 / width as f32;
467        let div_height = 1.0 / height as f32;
468
469        let mut grid = Vec::with_capacity(height * width * na * 2);
470        for y in 0..height {
471            for x in 0..width {
472                for _ in 0..na {
473                    grid.push(x as f32 - 0.5);
474                    grid.push(y as f32 - 0.5);
475                }
476            }
477        }
478        for ((p, g), anchor) in p
479            .chunks_exact(nc + 5)
480            .zip(grid.as_chunks::<2>().0)
481            .zip(anchors.iter().cycle())
482        {
483            let (x, y) = (p[0], p[1]);
484            let x = (x * 2.0 + g[0]) * div_width;
485            let y = (y * 2.0 + g[1]) * div_height;
486            let (w, h) = (p[2], p[3]);
487            let w = w * w * 4.0 * anchor[0];
488            let h = h * h * 4.0 * anchor[1];
489
490            bboxes.push(x);
491            bboxes.push(y);
492            bboxes.push(w);
493            bboxes.push(h);
494
495            if nc == 1 {
496                bscores.push(p[4]);
497            } else {
498                let obj = p[4];
499                let probs = p[5..].iter().map(|x| *x * obj);
500                bscores.extend(probs);
501            }
502        }
503    }
504    // Safe to unwrap since we ensured lengths will match above
505
506    debug_assert_eq!(bboxes.len() % 4, 0);
507    debug_assert_eq!(bscores.len() % nc, 0);
508
509    let bboxes = Array2::from_shape_vec((bboxes.len() / 4, 4), bboxes)
510        .expect("Failed to create bboxes array");
511    let bscores = Array2::from_shape_vec((bscores.len() / nc, nc), bscores)
512        .expect("Failed to create bscores array");
513    (bboxes, bscores)
514}
515
516/// Post processes ModelPack split detection into detection boxes,
517/// filtering out any boxes below the score threshold. Returns the boxes and
518/// scores tensors. Boxes are in XYWH format.
519pub(crate) fn postprocess_modelpack_split_float<T: AsPrimitive<f32>>(
520    outputs: &[ArrayView3<T>],
521    config: &[ModelPackDetectionConfig],
522) -> (Array2<f32>, Array2<f32>) {
523    let mut total_capacity = 0;
524    let mut nc = 0;
525    for (p, detail) in outputs.iter().zip(config) {
526        let shape = p.shape();
527        let na = detail.anchors.len();
528        nc = *shape
529            .last()
530            .expect("Shape must have at least one dimension")
531            / na
532            - 5;
533        total_capacity += shape[0] * shape[1] * na;
534    }
535    let mut bboxes = Vec::with_capacity(total_capacity * 4);
536    let mut bscores = Vec::with_capacity(total_capacity * nc);
537
538    for (p, detail) in outputs.iter().zip(config) {
539        let anchors = &detail.anchors;
540        let na = detail.anchors.len();
541        let shape = p.shape();
542        assert_eq!(
543            shape.iter().product::<usize>(),
544            p.len(),
545            "Shape product doesn't match tensor length"
546        );
547        let p_sigmoid = p.mapv(|x| fast_sigmoid_impl(x.as_()));
548        let p_sigmoid = p_sigmoid.as_standard_layout();
549
550        // Safe to unwrap since we ensured standard layout above
551        let p = p_sigmoid
552            .as_slice()
553            .expect("Sigmoids are not in standard layout");
554        let height = shape[0];
555        let width = shape[1];
556
557        let div_width = 1.0 / width as f32;
558        let div_height = 1.0 / height as f32;
559
560        let mut grid = Vec::with_capacity(height * width * na * 2);
561        for y in 0..height {
562            for x in 0..width {
563                for _ in 0..na {
564                    grid.push(x as f32 - 0.5);
565                    grid.push(y as f32 - 0.5);
566                }
567            }
568        }
569        for ((p, g), anchor) in p
570            .chunks_exact(nc + 5)
571            .zip(grid.as_chunks::<2>().0)
572            .zip(anchors.iter().cycle())
573        {
574            let (x, y) = (p[0], p[1]);
575            let x = (x * 2.0 + g[0]) * div_width;
576            let y = (y * 2.0 + g[1]) * div_height;
577            let (w, h) = (p[2], p[3]);
578            let w = w * w * 4.0 * anchor[0];
579            let h = h * h * 4.0 * anchor[1];
580
581            bboxes.push(x);
582            bboxes.push(y);
583            bboxes.push(w);
584            bboxes.push(h);
585
586            if nc == 1 {
587                bscores.push(p[4]);
588            } else {
589                let obj = p[4];
590                let probs = p[5..].iter().map(|x| *x * obj);
591                bscores.extend(probs);
592            }
593        }
594    }
595    // Safe to unwrap since we ensured lengths will match above
596
597    debug_assert_eq!(bboxes.len() % 4, 0);
598    debug_assert_eq!(bscores.len() % nc, 0);
599
600    let bboxes = Array2::from_shape_vec((bboxes.len() / 4, 4), bboxes)
601        .expect("Failed to create bboxes array");
602    let bscores = Array2::from_shape_vec((bscores.len() / nc, nc), bscores)
603        .expect("Failed to create bscores array");
604    (bboxes, bscores)
605}
606
607/// Fast approximation of `eˣ`, vendored bit-for-bit from the (abandoned)
608/// `fast-math` crate by Huon Wilson (MIT/Apache-2.0). Inlining the ~10-line
609/// Schraudolph float-bit-trick lets us drop the unmaintained dependency while
610/// keeping decoder outputs bit-identical to prior releases. Valid for roughly
611/// -88 ≤ x ≤ 88 (kept in range by the guard in `fast_sigmoid_impl`); max
612/// relative error < ~0.011.
613#[inline(always)]
614// Constants are reproduced verbatim from `fast-math` to stay bit-identical;
615// keep the upstream literals rather than rounding them to f32 precision.
616#[allow(clippy::excessive_precision)]
617fn exp_raw(x: f32) -> f32 {
618    const A: f32 = (1u32 << 23) as f32; // 2^SIGNIF for f32
619    const MASK: i32 = 0xff800000u32 as i32;
620    const EXP2_23: f32 = 1.1920929e-7;
621    const C0: f32 = 0.3371894346 * EXP2_23 * EXP2_23;
622    const C1: f32 = 0.657636276 * EXP2_23;
623    const C2: f32 = 1.00172476;
624
625    let a = A * core::f32::consts::LOG2_E;
626    let mul = (a * x) as i32;
627    let floor = mul & MASK;
628    let frac = (mul - floor) as f32;
629    let approx = (C0 * frac + C1) * frac + C2;
630    f32::from_bits(approx.to_bits().wrapping_add(floor as u32))
631}
632
633#[inline(always)]
634fn fast_sigmoid_impl(f: f32) -> f32 {
635    if f.abs() > 80.0 {
636        f.signum() * 0.5 + 0.5
637    } else {
638        // exp_raw is only valid for -88 < x < 88; the guard above keeps us in range
639        1.0 / (1.0 + exp_raw(-f))
640    }
641}
642
643/// Converts ModelPack segmentation into a 2D mask.
644/// The input segmentation is expected to have shape (H, W, num_classes).
645///
646/// The output mask will have shape (H, W), with values `0..num_classes` based
647/// on the argmax across the channels.
648///
649/// # Panics
650/// Panics if the input tensor does not have more than one channel.
651pub(crate) fn modelpack_segmentation_to_mask(segmentation: ArrayView3<u8>) -> Array2<u8> {
652    use argminmax::ArgMinMax;
653    assert!(
654        segmentation.shape()[2] > 1,
655        "Model Instance Segmentation should have shape (H, W, x) where x > 1"
656    );
657    let height = segmentation.shape()[0];
658    let width = segmentation.shape()[1];
659    let channels = segmentation.shape()[2];
660    let segmentation = segmentation.as_standard_layout();
661    // Safe to unwrap since we ensured standard layout above
662    let seg = segmentation
663        .as_slice()
664        .expect("Segmentation is not in standard layout");
665    let argmax = seg
666        .chunks_exact(channels)
667        .map(|x| x.argmax() as u8)
668        .collect::<Vec<_>>();
669
670    Array2::from_shape_vec((height, width), argmax).expect("Failed to create mask array")
671}
672
673#[cfg(test)]
674#[cfg_attr(coverage_nightly, coverage(off))]
675mod modelpack_tests {
676    #![allow(clippy::excessive_precision)]
677    use ndarray::Array3;
678
679    use crate::configs::{DecoderType, DimName};
680
681    use super::*;
682    #[test]
683    fn test_detection_config() {
684        let det = Detection {
685            anchors: Some(vec![[0.1, 0.13], [0.16, 0.30], [0.33, 0.23]]),
686            quantization: Some((0.1, 128).into()),
687            decoder: DecoderType::ModelPack,
688            shape: vec![1, 9, 17, 18],
689            dshape: vec![
690                (DimName::Batch, 1),
691                (DimName::Height, 9),
692                (DimName::Width, 17),
693                (DimName::NumAnchorsXFeatures, 18),
694            ],
695            normalized: Some(true),
696        };
697        let config = ModelPackDetectionConfig::try_from(&det).unwrap();
698        assert_eq!(
699            config,
700            ModelPackDetectionConfig {
701                anchors: vec![[0.1, 0.13], [0.16, 0.30], [0.33, 0.23]],
702                quantization: Some(Quantization::new(0.1, 128)),
703            }
704        );
705
706        let det = Detection {
707            anchors: None,
708            quantization: Some((0.1, 128).into()),
709            decoder: DecoderType::ModelPack,
710            shape: vec![1, 9, 17, 18],
711            dshape: vec![
712                (DimName::Batch, 1),
713                (DimName::Height, 9),
714                (DimName::Width, 17),
715                (DimName::NumAnchorsXFeatures, 18),
716            ],
717            normalized: Some(true),
718        };
719        let result = ModelPackDetectionConfig::try_from(&det);
720        assert!(
721            matches!(result, Err(DecoderError::InvalidConfig(s)) if s == "ModelPack Split Detection missing anchors")
722        );
723    }
724
725    #[test]
726    fn test_fast_sigmoid() {
727        fn full_sigmoid(x: f32) -> f32 {
728            1.0 / (1.0 + (-x).exp())
729        }
730        for i in -2550..=2550 {
731            let x = i as f32 * 0.1;
732            let fast = fast_sigmoid_impl(x);
733            let full = full_sigmoid(x);
734            let diff = (fast - full).abs();
735            assert!(
736                diff < 0.0005,
737                "Fast sigmoid differs from full sigmoid by {} at input {}",
738                diff,
739                x
740            );
741        }
742    }
743
744    /// The 80.0 cut-off is exclusive. At exactly -80 the smooth branch still
745    /// runs and returns a small positive number; the saturating branch would
746    /// return a hard 0.0, which is a different value, not a rounding of it.
747    #[test]
748    fn fast_sigmoid_saturates_beyond_the_cutoff_not_at_it() {
749        assert!(
750            fast_sigmoid_impl(-80.0) > 0.0,
751            "-80.0 is inside the smooth range, got {}",
752            fast_sigmoid_impl(-80.0)
753        );
754        assert_eq!(fast_sigmoid_impl(-80.5), 0.0);
755        assert_eq!(fast_sigmoid_impl(80.5), 1.0);
756    }
757
758    /// Exercises the float split decode end to end on one 1x1 cell with two
759    /// classes: the class count derived from the last dimension, the box
760    /// decode, and the objectness-weighted class scores.
761    #[test]
762    fn modelpack_split_float_decodes_boxes_and_objectness_weighted_scores() {
763        fn full_sigmoid(x: f32) -> f32 {
764            1.0 / (1.0 + (-x).exp())
765        }
766
767        // (H, W, na * (5 + nc)) with na = 1 and nc = 2.
768        let raw = Array3::from_shape_vec((1, 1, 7), vec![0.0, 0.0, 0.0, 0.0, 2.0, 1.0, -1.0])
769            .expect("shape matches the data");
770        let config = [ModelPackDetectionConfig {
771            anchors: vec![[2.0, 3.0]],
772            quantization: None,
773        }];
774
775        let (boxes, scores) = postprocess_modelpack_split_float(&[raw.view()], &config);
776
777        assert_eq!(boxes.shape(), &[1, 4]);
778        assert_eq!(scores.shape(), &[1, 2]);
779
780        // p = sigmoid(raw); grid cell (0,0) contributes -0.5, and 1/width and
781        // 1/height are both 1.0 for a 1x1 grid.
782        let obj = full_sigmoid(2.0);
783        let expected_box = [
784            0.5 * 2.0 - 0.5,
785            0.5 * 2.0 - 0.5,
786            0.5 * 0.5 * 4.0 * 2.0,
787            0.5 * 0.5 * 4.0 * 3.0,
788        ];
789        let expected_scores = [full_sigmoid(1.0) * obj, full_sigmoid(-1.0) * obj];
790
791        // fast_sigmoid_impl is accurate to 5e-4; the width and height terms
792        // square it and scale by 4 * anchor, so the box tolerance is looser
793        // than the score tolerance by exactly that factor.
794        for (i, want) in expected_box.iter().enumerate() {
795            assert!(
796                (boxes[[0, i]] - want).abs() < 1e-2,
797                "box[{i}] = {} want {want}",
798                boxes[[0, i]]
799            );
800        }
801        for (i, want) in expected_scores.iter().enumerate() {
802            assert!(
803                (scores[[0, i]] - want).abs() < 2e-3,
804                "score[{i}] = {} want {want}",
805                scores[[0, i]]
806            );
807        }
808    }
809
810    /// With one class the objectness is the score outright, and the scores
811    /// array stays a single column.
812    #[test]
813    fn modelpack_split_float_single_class_uses_objectness_as_the_score() {
814        let raw = Array3::from_shape_vec((1, 1, 6), vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
815            .expect("shape matches the data");
816        let config = [ModelPackDetectionConfig {
817            anchors: vec![[1.0, 1.0]],
818            quantization: None,
819        }];
820
821        let (boxes, scores) = postprocess_modelpack_split_float(&[raw.view()], &config);
822
823        assert_eq!(boxes.shape(), &[1, 4]);
824        assert_eq!(scores.shape(), &[1, 1]);
825        assert!((scores[[0, 0]] - 0.5).abs() < 2e-3, "{}", scores[[0, 0]]);
826    }
827
828    #[test]
829    fn test_modelpack_segmentation_to_mask() {
830        let seg = Array3::from_shape_vec(
831            (2, 2, 3),
832            vec![
833                0u8, 10, 5, // pixel (0,0)
834                20, 15, 25, // pixel (0,1)
835                30, 5, 10, // pixel (1,0)
836                0, 0, 0, // pixel (1,1)
837            ],
838        )
839        .unwrap();
840        let mask = modelpack_segmentation_to_mask(seg.view());
841        let expected_mask = Array2::from_shape_vec((2, 2), vec![1u8, 2, 0, 0]).unwrap();
842        assert_eq!(mask, expected_mask);
843    }
844
845    #[test]
846    #[should_panic(
847        expected = "Model Instance Segmentation should have shape (H, W, x) where x > 1"
848    )]
849    fn test_modelpack_segmentation_to_mask_invalid() {
850        let seg = Array3::from_shape_vec((2, 2, 1), vec![0u8, 10, 20, 30]).unwrap();
851        let _ = modelpack_segmentation_to_mask(seg.view());
852    }
853
854    #[test]
855    fn modelpack_split_float_multi_label_emits_every_class() {
856        // 1x1 grid, one anchor, two classes both saturated.
857        let p = ndarray::Array3::from_shape_vec(
858            (1, 1, 7),
859            vec![0.0f32, 0.0, 0.0, 0.0, 10.0, 10.0, 10.0],
860        )
861        .unwrap();
862        let cfg = ModelPackDetectionConfig {
863            anchors: vec![[0.5, 0.5]],
864            quantization: None,
865        };
866        let run = |nms: Nms, multi_label: bool| {
867            let mut out = Vec::with_capacity(8);
868            decode_modelpack_split_float(
869                &[p.view()],
870                std::slice::from_ref(&cfg),
871                0.5,
872                0.5,
873                Some(nms),
874                0,
875                8,
876                multi_label,
877                &mut out,
878            )
879            .unwrap();
880            let mut labels: Vec<usize> = out.iter().map(|b| b.label).collect();
881            labels.sort_unstable();
882            labels
883        };
884        assert_eq!(run(Nms::ClassAware, false).len(), 1);
885        assert_eq!(run(Nms::ClassAware, true), vec![0, 1]);
886        // Class-agnostic NMS runs over the multi-label candidates: the two
887        // classes share one bbox, so only one survives.
888        assert_eq!(run(Nms::ClassAgnostic, true).len(), 1);
889    }
890}