Skip to main content

edgefirst_decoder/semantic/
mod.rs

1// SPDX-FileCopyrightText: Copyright 2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Semantic segmentation decoding.
5//!
6//! Turns a per-pixel score map (`[C, h, w]` in any axis order) or a class map
7//! the model already computed into an `[H, W]` class map at any resolution,
8//! written into a caller-supplied tensor. Each output pixel is mapped through
9//! the letterbox geometry of the model input, so the padding is cropped and
10//! the class map covers the original image.
11//!
12//! Two decode modes are available ([`DecodeMode`]):
13//!
14//! - **Accurate** upsamples the scores of every class, then takes the argmax.
15//!   Pixels whose low-resolution neighbours agree on the winner skip the
16//!   per-class interpolation, which is exact because bilinear interpolation
17//!   is a convex combination.
18//! - **Fast** takes the argmax at the score map's resolution, then
19//!   upsamples the class map with nearest-neighbour sampling.
20//!
21//! Single-channel outputs are thresholded instead of argmaxed: foreground is
22//! class 1 and background class 0.
23//!
24//! # Examples
25//!
26//! ```rust,no_run
27//! use edgefirst_decoder::semantic::{decode_semantic, SemanticConfig, SemanticLayout, SemanticOptions};
28//! use edgefirst_decoder::schema::DecodeMode;
29//! use edgefirst_tensor::{DType, TensorDyn, TensorMemory};
30//!
31//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
32//! let config = SemanticConfig::scores(&[1, 150, 128, 168], 1, 2, 3)?;
33//! # let logits = TensorDyn::new(&[1, 150, 128, 168], DType::F32, Some(TensorMemory::Mem), None)?;
34//! let mut class_map = TensorDyn::new(&[480, 640], DType::U8, Some(TensorMemory::Mem), None)?;
35//! let options = SemanticOptions::new()
36//!     .with_mode(DecodeMode::Fast)
37//!     .with_content_region([0.0, 0.0, 1.0, 0.75]);
38//! decode_semantic(&logits, &config, &mut class_map, None, &options)?;
39//! # Ok(())
40//! # }
41//! ```
42
43mod kernels;
44
45use edgefirst_tensor::{CpuAccess, DType, QuantMode, TensorDyn, TensorMapTrait, TensorTrait};
46use half::f16;
47
48use crate::configs::DimName;
49use crate::schema::{
50    Activation, DecodeMode, DecoderKind, LogicalOutput, LogicalType, Upsample, UpsampleMethod,
51};
52use crate::{DecoderError, DecoderResult};
53use kernels::{ClassOut, ConfKind, ConfOut, Dequant, Grid, Score};
54
55/// Largest class count a class map can index (`u16`).
56const MAX_CLASSES: usize = 1 << 16;
57
58/// What a semantic output holds.
59#[derive(Debug, Clone, Copy, PartialEq)]
60#[non_exhaustive]
61pub enum SemanticInput {
62    /// Per-pixel class scores: logits or probabilities.
63    Scores,
64    /// A class index per pixel, with the argmax already applied in the graph.
65    ClassMap,
66}
67
68/// Axis positions of a semantic output in its tensor shape.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct SemanticLayout {
71    /// Class axis; `None` for a single channel or a class map.
72    pub classes: Option<usize>,
73    /// Height axis.
74    pub height: usize,
75    /// Width axis.
76    pub width: usize,
77}
78
79/// Static description of a semantic output, usually built from the model's
80/// `edgefirst.json` by [`SemanticConfig::from_logical`].
81#[derive(Debug, Clone, PartialEq)]
82pub struct SemanticConfig {
83    input: SemanticInput,
84    name: Option<String>,
85    position: Option<(usize, usize)>,
86    shape: Vec<usize>,
87    layout: SemanticLayout,
88    upsample: Upsample,
89    mode: DecodeMode,
90    threshold: Option<f32>,
91    activation: Option<Activation>,
92    activation_required: Option<Activation>,
93    quantization: Option<edgefirst_tensor::Quantization>,
94}
95
96impl SemanticConfig {
97    /// Score map of `shape`, with the class, height and width axes at the
98    /// given positions. Every other axis must have size 1.
99    ///
100    /// Defaults: bilinear upsampling with `align_corners = false`,
101    /// [`DecodeMode::Accurate`], no activation applied, not quantized.
102    pub fn scores(
103        shape: &[usize],
104        class_axis: usize,
105        height_axis: usize,
106        width_axis: usize,
107    ) -> DecoderResult<Self> {
108        let layout = SemanticLayout {
109            classes: Some(class_axis),
110            height: height_axis,
111            width: width_axis,
112        };
113        Self::new(SemanticInput::Scores, shape, layout)
114    }
115
116    /// Class map of `shape` with the height and width axes at the given
117    /// positions. Every other axis must have size 1.
118    pub fn class_map(
119        shape: &[usize],
120        height_axis: usize,
121        width_axis: usize,
122    ) -> DecoderResult<Self> {
123        let layout = SemanticLayout {
124            classes: None,
125            height: height_axis,
126            width: width_axis,
127        };
128        Self::new(SemanticInput::ClassMap, shape, layout)
129    }
130
131    fn new(input: SemanticInput, shape: &[usize], layout: SemanticLayout) -> DecoderResult<Self> {
132        let axes = [layout.classes, Some(layout.height), Some(layout.width)];
133        let named: Vec<usize> = axes.iter().flatten().copied().collect();
134        if named.iter().any(|&a| a >= shape.len()) {
135            return Err(DecoderError::InvalidShape(format!(
136                "semantic axes {named:?} out of range for shape {shape:?}"
137            )));
138        }
139        if named.len() != named.iter().collect::<std::collections::HashSet<_>>().len() {
140            return Err(DecoderError::InvalidShape(format!(
141                "semantic axes {named:?} must be distinct"
142            )));
143        }
144        if let Some((axis, &n)) = shape
145            .iter()
146            .enumerate()
147            .find(|&(i, &n)| n != 1 && !named.contains(&i))
148        {
149            return Err(DecoderError::InvalidShape(format!(
150                "semantic output shape {shape:?} has unnamed axis {axis} of size {n}"
151            )));
152        }
153        if shape.contains(&0) {
154            return Err(DecoderError::InvalidShape(format!(
155                "semantic output shape {shape:?} is empty"
156            )));
157        }
158        let config = Self {
159            input,
160            name: None,
161            position: None,
162            shape: shape.to_vec(),
163            layout,
164            upsample: Upsample {
165                method: UpsampleMethod::Bilinear,
166                align_corners: false,
167            },
168            mode: DecodeMode::Accurate,
169            threshold: None,
170            activation: None,
171            activation_required: None,
172            quantization: None,
173        };
174        if config.classes() > MAX_CLASSES {
175            return Err(DecoderError::NotSupported(format!(
176                "{} classes exceed the decoder's limit of {MAX_CLASSES}",
177                config.classes()
178            )));
179        }
180        Ok(config)
181    }
182
183    /// Builds the configuration of a `segmentation` or `masks` logical output
184    /// with `decoder: semantic`, or of a ModelPack `segmentation` output, which
185    /// is NHWC and decoded in [`DecodeMode::Fast`] unless it says otherwise.
186    pub fn from_logical(logical: &LogicalOutput) -> DecoderResult<Self> {
187        let label = logical.name.as_deref().unwrap_or("<anonymous>");
188        if logical.is_split() {
189            return Err(DecoderError::NotSupported(format!(
190                "semantic output `{label}` is split into physical tensors"
191            )));
192        }
193        let (input, role) = match logical.type_ {
194            Some(LogicalType::Segmentation) => (SemanticInput::Scores, "segmentation"),
195            Some(LogicalType::Masks) => (SemanticInput::ClassMap, "masks"),
196            other => {
197                return Err(DecoderError::InvalidConfig(format!(
198                    "semantic output `{label}` has type {other:?}; expected segmentation or masks"
199                )))
200            }
201        };
202        crate::configs::validate_dshape(role, &logical.shape, &logical.dshape)?;
203        let modelpack = logical.decoder == Some(DecoderKind::ModelPack);
204        let mut config = if modelpack && input == SemanticInput::Scores {
205            let (c, h, w) = modelpack_axes(&logical.shape, &logical.dshape)?;
206            Self::scores(&logical.shape, c, h, w)?.with_mode(DecodeMode::Fast)
207        } else {
208            Self::new(input, &logical.shape, layout_from_dshape(logical, input)?)?
209        };
210        config.name = logical.name.clone();
211        if let Some(upsample) = logical.semantic.upsample {
212            config = config.with_upsample(upsample)?;
213        }
214        if let Some(mode) = logical.semantic.decode_mode {
215            config.mode = mode;
216        }
217        config.threshold = logical.semantic.threshold;
218        config.activation = logical.activation_applied;
219        config.activation_required = logical.activation_required;
220        config.check_activation()?;
221        if let Some(q) = &logical.quantization {
222            config.quantization = Some(q.try_into().map_err(|e| {
223                DecoderError::InvalidConfig(format!("semantic output `{label}` quantization: {e}"))
224            })?);
225        }
226        Ok(config)
227    }
228
229    /// The segmentation output of a ModelPack model configured without a
230    /// schema: NHWC unless `dshape` names the axes, decoded in
231    /// [`DecodeMode::Fast`] by default.
232    pub(crate) fn from_modelpack(seg: &crate::configs::Segmentation) -> DecoderResult<Self> {
233        let (classes, height, width) = modelpack_axes(&seg.shape, &seg.dshape)?;
234        let mut config =
235            Self::scores(&seg.shape, classes, height, width)?.with_mode(DecodeMode::Fast);
236        if let Some(q) = &seg.quantization {
237            config.quantization = Some(edgefirst_tensor::Quantization::per_tensor(q.0, q.1));
238        }
239        Ok(config)
240    }
241
242    /// Sets the score-map upsampling. Bicubic is not supported.
243    pub fn with_upsample(mut self, upsample: Upsample) -> DecoderResult<Self> {
244        if !matches!(
245            upsample.method,
246            UpsampleMethod::Bilinear | UpsampleMethod::Nearest
247        ) {
248            return Err(DecoderError::NotSupported(format!(
249                "semantic upsampling {:?} is not supported",
250                upsample.method
251            )));
252        }
253        self.upsample = upsample;
254        Ok(self)
255    }
256
257    /// Sets the default decode mode.
258    pub fn with_mode(mut self, mode: DecodeMode) -> Self {
259        self.mode = mode;
260        self
261    }
262
263    /// Sets the single-channel foreground threshold, in the space of the
264    /// scores after `activation`.
265    pub fn with_threshold(mut self, threshold: f32) -> Self {
266        self.threshold = Some(threshold);
267        self
268    }
269
270    /// Declares an activation the model already applied to the scores.
271    ///
272    /// # Errors
273    ///
274    /// See [`Self::with_activation_required`].
275    pub fn with_activation(mut self, activation: Activation) -> DecoderResult<Self> {
276        self.activation = Some(activation);
277        self.check_activation()?;
278        Ok(self)
279    }
280
281    /// Declares the activation the scores still need. It is applied after
282    /// resampling, as reference pipelines do, so it leaves the class map
283    /// unchanged and sets how [`decode_semantic`] computes confidence.
284    ///
285    /// # Errors
286    ///
287    /// [`DecoderError::InvalidConfig`] when an activation is both applied
288    /// and required, or a single channel declares softmax;
289    /// [`DecoderError::NotSupported`] for `tanh`.
290    pub fn with_activation_required(mut self, activation: Activation) -> DecoderResult<Self> {
291        self.activation_required = Some(activation);
292        self.check_activation()?;
293        Ok(self)
294    }
295
296    fn check_activation(&self) -> DecoderResult<()> {
297        let single = self.input == SemanticInput::Scores && self.grid().classes == 1;
298        match (self.activation, self.activation_required) {
299            (Some(_), Some(_)) => Err(DecoderError::InvalidConfig(
300                "a semantic output cannot both apply and require an activation".to_string(),
301            )),
302            (Some(Activation::Tanh), _) | (_, Some(Activation::Tanh)) => Err(
303                DecoderError::NotSupported("tanh semantic scores are not supported".to_string()),
304            ),
305            (Some(Activation::Softmax), _) | (_, Some(Activation::Softmax)) if single => {
306                Err(DecoderError::InvalidConfig(
307                    "softmax over a single channel is constant; declare sigmoid".to_string(),
308                ))
309            }
310            _ => Ok(()),
311        }
312    }
313
314    /// Quantization of integer scores, used when the tensor carries none.
315    pub fn with_quantization(mut self, quantization: edgefirst_tensor::Quantization) -> Self {
316        self.quantization = Some(quantization);
317        self
318    }
319
320    /// Names the output so [`select_output`] prefers the tensor of that name.
321    pub fn with_name(mut self, name: impl Into<String>) -> Self {
322        self.name = Some(name.into());
323        self
324    }
325
326    /// Records the output's index among the model's `count` outputs, which
327    /// [`select_output`] uses when several outputs share its shape.
328    pub fn with_position(mut self, index: usize, count: usize) -> Self {
329        self.position = Some((index, count));
330        self
331    }
332
333    /// What the output holds.
334    pub fn input(&self) -> SemanticInput {
335        self.input
336    }
337
338    /// Output name, when known.
339    pub fn name(&self) -> Option<&str> {
340        self.name.as_deref()
341    }
342
343    /// Expected tensor shape.
344    pub fn shape(&self) -> &[usize] {
345        &self.shape
346    }
347
348    /// Axis positions in [`Self::shape`].
349    pub fn layout(&self) -> SemanticLayout {
350        self.layout
351    }
352
353    /// Number of classes the decoded class map can contain: the channel
354    /// count, 2 for a single thresholded channel, 0 when unknown (a class
355    /// map).
356    pub fn classes(&self) -> usize {
357        match (self.input, self.layout.classes) {
358            (SemanticInput::ClassMap, _) => 0,
359            (_, Some(axis)) if self.shape[axis] > 1 => self.shape[axis],
360            _ => 2,
361        }
362    }
363
364    /// Score-map height.
365    pub fn height(&self) -> usize {
366        self.shape[self.layout.height]
367    }
368
369    /// Score-map width.
370    pub fn width(&self) -> usize {
371        self.shape[self.layout.width]
372    }
373
374    /// Score-map upsampling.
375    pub fn upsample(&self) -> Upsample {
376        self.upsample
377    }
378
379    /// Default decode mode.
380    pub fn mode(&self) -> DecodeMode {
381        self.mode
382    }
383
384    /// Single-channel foreground threshold in the space of the scores:
385    /// the configured value, else 0.5 for probabilities and 0.0 for logits.
386    pub fn threshold(&self) -> f32 {
387        self.threshold.unwrap_or(match self.activation {
388            Some(Activation::Sigmoid) | Some(Activation::Softmax) => 0.5,
389            _ => 0.0,
390        })
391    }
392
393    /// Whether `tensor` has this output's shape, ignoring axes of size 1.
394    pub fn matches(&self, tensor: &TensorDyn) -> bool {
395        squeeze(tensor.shape()) == squeeze(&self.shape)
396    }
397
398    fn grid(&self) -> Grid {
399        let mut strides = vec![1usize; self.shape.len()];
400        for i in (0..self.shape.len().saturating_sub(1)).rev() {
401            strides[i] = strides[i + 1] * self.shape[i + 1];
402        }
403        let (classes, cs) = match self.layout.classes {
404            Some(axis) => (self.shape[axis], strides[axis]),
405            None => (1, 0),
406        };
407        Grid {
408            classes,
409            height: self.height(),
410            width: self.width(),
411            cs,
412            ys: strides[self.layout.height],
413            xs: strides[self.layout.width],
414        }
415    }
416
417    /// Element strides of `input`, following its row pitch when its rows
418    /// are padded.
419    fn grid_for(&self, input: &TensorDyn) -> DecoderResult<Grid> {
420        let mut grid = self.grid();
421        let Some(strides) = crate::decoder::tensor_bridge::padded_strides(input)? else {
422            return Ok(grid);
423        };
424        // Shapes match up to unit axes, whose strides are never used.
425        let stride = |axis: usize, packed: usize| {
426            squeezed_axis(&self.shape, axis)
427                .and_then(|k| {
428                    input
429                        .shape()
430                        .iter()
431                        .enumerate()
432                        .filter(|&(_, &n)| n > 1)
433                        .nth(k)
434                })
435                .map_or(packed, |(i, _)| strides[i])
436        };
437        if let Some(axis) = self.layout.classes {
438            grid.cs = stride(axis, grid.cs);
439        }
440        grid.ys = stride(self.layout.height, grid.ys);
441        grid.xs = stride(self.layout.width, grid.xs);
442        Ok(grid)
443    }
444}
445
446/// Fails unless every element `g` addresses lies within `len` elements.
447fn check_extent(len: usize, g: Grid, what: &str) -> DecoderResult<()> {
448    let extent = (g.classes - 1) * g.cs + (g.height - 1) * g.ys + (g.width - 1) * g.xs + 1;
449    if extent > len {
450        return Err(DecoderError::InvalidShape(format!(
451            "{what} holds {len} elements but its layout addresses {extent}"
452        )));
453    }
454    Ok(())
455}
456
457fn squeeze(shape: &[usize]) -> Vec<usize> {
458    shape.iter().copied().filter(|&n| n != 1).collect()
459}
460
461/// Class axis of a rank-4 ModelPack segmentation output: the axis named
462/// `num_classes`, else the NHWC channel axis.
463pub(crate) fn modelpack_class_axis(dshape: &[(DimName, usize)]) -> usize {
464    dshape
465        .iter()
466        .position(|(n, _)| *n == DimName::NumClasses)
467        .unwrap_or(3)
468}
469
470/// `(classes, height, width)` axes of a ModelPack segmentation output: NHWC,
471/// with any axis `dshape` names taking its named position.
472fn modelpack_axes(
473    shape: &[usize],
474    dshape: &[(DimName, usize)],
475) -> DecoderResult<(usize, usize, usize)> {
476    if shape.len() != 4 {
477        return Err(DecoderError::InvalidShape(format!(
478            "ModelPack segmentation shape {shape:?}; expected rank 4"
479        )));
480    }
481    let find =
482        |name: DimName, nhwc: usize| dshape.iter().position(|(n, _)| *n == name).unwrap_or(nhwc);
483    Ok((
484        modelpack_class_axis(dshape),
485        find(DimName::Height, 1),
486        find(DimName::Width, 2),
487    ))
488}
489
490fn layout_from_dshape(
491    logical: &LogicalOutput,
492    input: SemanticInput,
493) -> DecoderResult<SemanticLayout> {
494    let label = logical.name.as_deref().unwrap_or("<anonymous>");
495    let find = |name: DimName| logical.dshape.iter().position(|(n, _)| *n == name);
496    if !logical.dshape.is_empty() {
497        let (Some(height), Some(width)) = (find(DimName::Height), find(DimName::Width)) else {
498            return Err(DecoderError::InvalidConfig(format!(
499                "semantic output `{label}` dshape must name height and width"
500            )));
501        };
502        let classes = match input {
503            SemanticInput::Scores => find(DimName::NumClasses),
504            SemanticInput::ClassMap => None,
505        };
506        return Ok(SemanticLayout {
507            classes,
508            height,
509            width,
510        });
511    }
512    // Without dshape only an unambiguous single-plane layout is accepted.
513    let spatial: Vec<usize> = (0..logical.shape.len())
514        .filter(|&i| logical.shape[i] != 1)
515        .collect();
516    match spatial.as_slice() {
517        [h, w] => Ok(SemanticLayout {
518            classes: None,
519            height: *h,
520            width: *w,
521        }),
522        _ => Err(DecoderError::InvalidConfig(format!(
523            "semantic output `{label}` needs a dshape naming num_classes, height and width"
524        ))),
525    }
526}
527
528/// Runtime options of one semantic decode.
529#[derive(Debug, Clone, Copy, Default, PartialEq)]
530#[non_exhaustive]
531pub struct SemanticOptions {
532    /// Decode mode; `None` uses the model's default.
533    pub mode: Option<DecodeMode>,
534    /// Image content within the model input as normalized
535    /// `[xmin, ymin, xmax, ymax]`, as produced by a letterbox resize. The
536    /// region is cropped and stretched over the whole class map. `None`
537    /// means the image fills the input.
538    pub content_region: Option<[f32; 4]>,
539}
540
541impl SemanticOptions {
542    /// Default options: the model's decode mode, the image filling the input.
543    pub fn new() -> Self {
544        Self::default()
545    }
546
547    /// Overrides the model's decode mode.
548    pub fn with_mode(mut self, mode: DecodeMode) -> Self {
549        self.mode = Some(mode);
550        self
551    }
552
553    /// Sets the content region: where the image sits in the model input.
554    pub fn with_content_region(mut self, region: [f32; 4]) -> Self {
555        self.content_region = Some(region);
556        self
557    }
558
559    /// Sets the content region from the options of the convert that
560    /// prepared the model input, so the class map uses exactly the geometry
561    /// the convert painted. `src` is the source image and `dst` the model
562    /// input, each `(width, height)`. A stretch leaves the region unset.
563    ///
564    /// # Errors
565    ///
566    /// [`DecoderError::InvalidConfig`] when the convert geometry does not
567    /// resolve.
568    ///
569    /// ```
570    /// use edgefirst_decoder::semantic::SemanticOptions;
571    /// use edgefirst_tensor::PreprocessSpec;
572    ///
573    /// let options = SemanticOptions::new().with_convert(&PreprocessSpec::new(), (1280, 720), (640, 640))?;
574    /// assert_eq!(options.content_region, Some([0.0, 0.21875, 1.0, 0.78125]));
575    /// # Ok::<(), edgefirst_decoder::DecoderError>(())
576    /// ```
577    pub fn with_convert(
578        mut self,
579        convert: &impl edgefirst_tensor::ContentRegionSource,
580        src: (usize, usize),
581        dst: (usize, usize),
582    ) -> DecoderResult<Self> {
583        let region = convert
584            .content_region(src.0, src.1, dst.0, dst.1)
585            .map_err(|e| DecoderError::InvalidConfig(format!("content region: {e}")))?;
586        self.content_region = region.letterboxed();
587        Ok(self)
588    }
589}
590
591/// Picks the tensor of a semantic output from a model's outputs: the one
592/// named like the output, else the only one with its shape, else the one at
593/// the output's position when every model output was passed.
594pub fn select_output<'a>(
595    outputs: &[&'a TensorDyn],
596    config: &SemanticConfig,
597) -> DecoderResult<&'a TensorDyn> {
598    let mut found = outputs.iter().copied().filter(|t| config.matches(t));
599    let (first, second) = (found.next(), found.next());
600    if let Some(name) = config.name() {
601        if let Some(t) = outputs
602            .iter()
603            .copied()
604            .find(|t| t.name() == name && config.matches(t))
605        {
606            return Ok(t);
607        }
608    }
609    match (first, second, config.position) {
610        (Some(t), None, _) => Ok(t),
611        (Some(_), Some(_), Some((index, count))) if outputs.len() == count => outputs
612            .get(index)
613            .copied()
614            .filter(|t| config.matches(t))
615            .ok_or_else(|| {
616                DecoderError::InvalidConfig(format!(
617                    "the output at position {index} does not have the semantic output's shape {:?}",
618                    config.shape()
619                ))
620            }),
621        (Some(_), Some(_), _) => Err(DecoderError::InvalidConfig(format!(
622            "several outputs have the semantic output's shape {:?}; pass the outputs in model order or name the tensors",
623            config.shape()
624        ))),
625        (None, _, _) => Err(DecoderError::InvalidShape(format!(
626            "no output has the semantic output's shape {:?}; got {:?}",
627            config.shape(),
628            outputs.iter().map(|t| t.shape().to_vec()).collect::<Vec<_>>()
629        ))),
630    }
631}
632
633/// Decodes a semantic output into `dst`, an `[H, W]` class map of dtype
634/// `u8` (up to 256 classes) or `u16` (up to 65 536).
635///
636/// `confidence`, when given, receives the winning class's probability as an
637/// `[H, W]` map of `f32` in `[0, 1]` or `u8` scaled to 255. It is computed at
638/// the score map's resolution and upsampled, so it is approximate and meant
639/// for overlays. Class-map inputs have no confidence.
640///
641/// # Errors
642///
643/// - [`DecoderError::InvalidShape`] if `input` does not have the configured
644///   shape, or `dst` and `confidence` are not matching `[H, W]` tensors.
645/// - [`DecoderError::NotSupported`] for unsupported dtypes, or when the
646///   class count exceeds what the dtype of `dst` holds.
647/// - [`DecoderError::InvalidConfig`] for an invalid letterbox or
648///   quantization, or a class-map value `dst` cannot hold.
649/// - [`DecoderError::Tensor`] carrying
650///   [`CpuAccessDenied`](edgefirst_tensor::Error::CpuAccessDenied) when
651///   `input` is not declared CPU-readable, or `dst` or `confidence` not
652///   CPU-writable.
653pub fn decode_semantic(
654    input: &TensorDyn,
655    config: &SemanticConfig,
656    dst: &mut TensorDyn,
657    confidence: Option<&mut TensorDyn>,
658    options: &SemanticOptions,
659) -> DecoderResult<()> {
660    if !config.matches(input) {
661        return Err(DecoderError::InvalidShape(format!(
662            "semantic input shape {:?} does not match the configured {:?}",
663            input.shape(),
664            config.shape()
665        )));
666    }
667    let (out_h, out_w) = class_map_dims(dst, "class map")?;
668    if let Some(conf) = confidence.as_deref() {
669        if class_map_dims(conf, "confidence")? != (out_h, out_w) {
670            return Err(DecoderError::InvalidShape(format!(
671                "confidence shape {:?} differs from the class map's {:?}",
672                conf.shape(),
673                dst.shape()
674            )));
675        }
676        if config.input == SemanticInput::ClassMap {
677            return Err(DecoderError::NotSupported(
678                "a class-map output has no confidence".to_string(),
679            ));
680        }
681    }
682    let capacity = match dst.dtype() {
683        DType::U8 => u8::CAPACITY,
684        DType::U16 => u16::CAPACITY,
685        other => {
686            return Err(DecoderError::NotSupported(format!(
687                "class map dtype {other:?}; use u8 or u16"
688            )))
689        }
690    };
691    if config.classes() > capacity {
692        return Err(DecoderError::NotSupported(format!(
693            "{} classes do not fit a {:?} class map",
694            config.classes(),
695            dst.dtype()
696        )));
697    }
698
699    input.cpu_access().require(CpuAccess::Read)?;
700    dst.cpu_access().require(CpuAccess::Write)?;
701    if let Some(conf) = confidence.as_deref() {
702        conf.cpu_access().require(CpuAccess::Write)?;
703    }
704
705    let geometry = Geometry::new(config, options, out_h, out_w)?;
706    match config.input {
707        SemanticInput::ClassMap => decode_class_map(input, config, &geometry, dst),
708        SemanticInput::Scores => decode_scores(input, config, &geometry, dst, confidence),
709    }
710}
711
712/// Validates an `[H, W]` output tensor and returns its dimensions.
713fn class_map_dims(t: &TensorDyn, what: &str) -> DecoderResult<(usize, usize)> {
714    match *t.shape() {
715        [h, w] if h > 0 && w > 0 => {
716            if t.row_stride().is_some_and(|s| s != w * t.dtype().size()) {
717                return Err(DecoderError::NotSupported(format!(
718                    "{what} tensor has a padded row stride"
719                )));
720            }
721            Ok((h, w))
722        }
723        _ => Err(DecoderError::InvalidShape(format!(
724            "{what} tensor shape {:?}; expected [H, W]",
725            t.shape()
726        ))),
727    }
728}
729
730/// Output-to-source sampling resolved from the letterbox and mode.
731struct Geometry {
732    mode: DecodeMode,
733    method: UpsampleMethod,
734    align_corners: bool,
735    roi: [f32; 4],
736    out_h: usize,
737    out_w: usize,
738    src_h: usize,
739    src_w: usize,
740}
741
742impl Geometry {
743    fn new(
744        config: &SemanticConfig,
745        options: &SemanticOptions,
746        out_h: usize,
747        out_w: usize,
748    ) -> DecoderResult<Self> {
749        let [x0, y0, x1, y1] = options.content_region.unwrap_or([0.0, 0.0, 1.0, 1.0]);
750        let valid =
751            |a: f32, b: f32| a.is_finite() && b.is_finite() && 0.0 <= a && a < b && b <= 1.0;
752        if !valid(x0, x1) || !valid(y0, y1) {
753            return Err(DecoderError::InvalidConfig(format!(
754                "content region {:?} must satisfy 0 <= min < max <= 1",
755                [x0, y0, x1, y1]
756            )));
757        }
758        let (src_h, src_w) = (config.height(), config.width());
759        Ok(Self {
760            mode: options.mode.unwrap_or(config.mode),
761            method: config.upsample.method,
762            align_corners: config.upsample.align_corners,
763            roi: [
764                x0 * src_w as f32,
765                y0 * src_h as f32,
766                (x1 - x0) * src_w as f32,
767                (y1 - y0) * src_h as f32,
768            ],
769            out_h,
770            out_w,
771            src_h,
772            src_w,
773        })
774    }
775
776    /// Whether scores are resampled before the argmax or threshold.
777    fn bilinear_scores(&self) -> bool {
778        self.mode == DecodeMode::Accurate && self.method == UpsampleMethod::Bilinear
779    }
780
781    fn nearest(&self) -> (Vec<u32>, Vec<u32>) {
782        let [x0, y0, w, h] = self.roi;
783        (
784            kernels::nearest_taps(self.out_h, self.src_h, y0, h),
785            kernels::nearest_taps(self.out_w, self.src_w, x0, w),
786        )
787    }
788
789    fn bilinear(&self) -> (Vec<kernels::Tap>, Vec<kernels::Tap>) {
790        let [x0, y0, w, h] = self.roi;
791        (
792            kernels::bilinear_taps(self.out_h, self.src_h, y0, h, self.align_corners),
793            kernels::bilinear_taps(self.out_w, self.src_w, x0, w, self.align_corners),
794        )
795    }
796}
797
798/// Real-value map of a score tensor: per-tensor affine, or per-channel
799/// scales that require dequantizing first.
800enum Quant {
801    PerTensor(Dequant),
802    PerChannel {
803        scales: Vec<f32>,
804        zero_points: Vec<i32>,
805    },
806}
807
808/// Position of `axis` among the axes of `shape` longer than 1, so axes of
809/// two shapes that differ only in unit axes compare equal.
810fn squeezed_axis(shape: &[usize], axis: usize) -> Option<usize> {
811    (shape.get(axis)? > &1).then(|| shape[..axis].iter().filter(|&&n| n > 1).count())
812}
813
814/// The real-value map of `input`. `needs_real` marks decodes that compare
815/// against a real threshold or compute a confidence; integer scores without
816/// quantization are then an error rather than taken at face value.
817fn resolve_quant(
818    input: &TensorDyn,
819    config: &SemanticConfig,
820    needs_real: bool,
821) -> DecoderResult<Quant> {
822    if matches!(input.dtype(), DType::F16 | DType::F32) {
823        return Ok(Quant::PerTensor(Dequant::IDENTITY));
824    }
825    let (q, shape) = match (input.quantization(), config.quantization.as_ref()) {
826        (Some(q), _) => (q, input.shape()),
827        (None, Some(q)) => (q, config.shape()),
828        (None, None) if needs_real => {
829            return Err(DecoderError::QuantMissing {
830                dtype: input.dtype(),
831                role: "semantic scores",
832                level: 0,
833            })
834        }
835        (None, None) => return Ok(Quant::PerTensor(Dequant::IDENTITY)),
836    };
837    let check = |s: f32| {
838        if s.is_finite() && s > 0.0 {
839            Ok(s)
840        } else {
841            Err(DecoderError::InvalidConfig(format!(
842                "semantic quantization scale {s} must be positive"
843            )))
844        }
845    };
846    let per_channel = |scales: &[f32], zps: Vec<i32>, axis: usize| {
847        let class_axis = config
848            .layout
849            .classes
850            .and_then(|a| squeezed_axis(config.shape(), a));
851        if class_axis.is_none() || squeezed_axis(shape, axis) != class_axis {
852            return Err(DecoderError::NotSupported(format!(
853                "per-channel quantization on axis {axis}, which is not the class axis"
854            )));
855        }
856        let classes = config.grid().classes;
857        if scales.len() != classes || zps.len() != classes {
858            return Err(DecoderError::InvalidConfig(format!(
859                "per-channel quantization has {} scales and {} zero points for {classes} classes",
860                scales.len(),
861                zps.len()
862            )));
863        }
864        scales.iter().try_for_each(|&s| check(s).map(|_| ()))?;
865        Ok(Quant::PerChannel {
866            scales: scales.to_vec(),
867            zero_points: zps,
868        })
869    };
870    match q.mode() {
871        QuantMode::PerTensorSymmetric { scale } => Ok(Quant::PerTensor(Dequant {
872            scale: check(scale)?,
873            zero_point: 0.0,
874        })),
875        QuantMode::PerTensor { scale, zero_point } => Ok(Quant::PerTensor(Dequant {
876            scale: check(scale)?,
877            zero_point: zero_point as f32,
878        })),
879        QuantMode::PerChannelSymmetric { scales, axis } => {
880            per_channel(scales, vec![0; scales.len()], axis)
881        }
882        QuantMode::PerChannel {
883            scales,
884            zero_points,
885            axis,
886        } => per_channel(scales, zero_points.to_vec(), axis),
887    }
888}
889
890fn map_err(what: &str) -> impl FnOnce(edgefirst_tensor::Error) -> DecoderError + '_ {
891    move |e| {
892        DecoderError::from_map(e, |e| {
893            DecoderError::InvalidConfig(format!("{what} map failed: {e}"))
894        })
895    }
896}
897
898fn decode_scores(
899    input: &TensorDyn,
900    config: &SemanticConfig,
901    geometry: &Geometry,
902    dst: &mut TensorDyn,
903    confidence: Option<&mut TensorDyn>,
904) -> DecoderResult<()> {
905    let g = config.grid_for(input)?;
906    let needs_real = g.classes == 1 || confidence.is_some();
907    macro_rules! typed {
908        ($t:ty) => {{
909            let map = input
910                .as_typed::<$t>()
911                .ok_or_else(|| DecoderError::Internal("semantic input dtype".to_string()))?
912                .map_read()
913                .map_err(map_err("semantic input"))?;
914            check_extent(map.as_slice().len(), g, "semantic input")?;
915            match resolve_quant(input, config, needs_real)? {
916                Quant::PerTensor(dq) => {
917                    scores_into(map.as_slice(), g, config, dq, geometry, dst, confidence)
918                }
919                Quant::PerChannel {
920                    scales,
921                    zero_points,
922                } => {
923                    let real = dequantize_per_channel(map.as_slice(), g, &scales, &zero_points);
924                    scores_into(
925                        &real,
926                        g,
927                        config,
928                        Dequant::IDENTITY,
929                        geometry,
930                        dst,
931                        confidence,
932                    )
933                }
934            }
935        }};
936    }
937    match input.dtype() {
938        DType::F32 => typed!(f32),
939        DType::F16 => typed!(f16),
940        DType::U8 => typed!(u8),
941        DType::I8 => typed!(i8),
942        DType::U16 => typed!(u16),
943        DType::I16 => typed!(i16),
944        other => Err(DecoderError::NotSupported(format!(
945            "semantic score dtype {other:?}"
946        ))),
947    }
948}
949
950fn dequantize_per_channel<T: Score>(
951    data: &[T],
952    g: Grid,
953    scales: &[f32],
954    zero_points: &[i32],
955) -> Vec<f32> {
956    let mut out = vec![0f32; data.len()];
957    for (c, (&s, &z)) in scales.iter().zip(zero_points).enumerate() {
958        let z = z as f32;
959        for y in 0..g.height {
960            for x in 0..g.width {
961                let i = c * g.cs + y * g.ys + x * g.xs;
962                out[i] = (data[i].to_f32() - z) * s;
963            }
964        }
965    }
966    out
967}
968
969fn scores_into<T: Score>(
970    data: &[T],
971    g: Grid,
972    config: &SemanticConfig,
973    dq: Dequant,
974    geometry: &Geometry,
975    dst: &mut TensorDyn,
976    confidence: Option<&mut TensorDyn>,
977) -> DecoderResult<()> {
978    let single = g.classes == 1;
979    let cut = dq.stored(config.threshold());
980    // The low-resolution winners feed the fast path, the accurate early-out
981    // and the confidence map; single-channel accurate decodes need them only
982    // for confidence.
983    let needs_winners = !single || !geometry.bilinear_scores() || confidence.is_some();
984    let winners = needs_winners.then(|| {
985        if single {
986            kernels::threshold_map(data, g, cut)
987        } else {
988            kernels::argmax_map(data, g)
989        }
990    });
991    match dst.dtype() {
992        DType::U8 => write_classes::<T, u8>(data, g, cut, winners.as_deref(), geometry, dst)?,
993        _ => write_classes::<T, u16>(data, g, cut, winners.as_deref(), geometry, dst)?,
994    }
995    let (Some(conf), Some(winners)) = (confidence, winners) else {
996        return Ok(());
997    };
998    let kind = match (single, config.activation, config.activation_required) {
999        (true, Some(_), _) => ConfKind::BinaryProbability,
1000        (true, None, _) => ConfKind::BinaryLogit,
1001        (false, Some(_), _) => ConfKind::Probability,
1002        (false, None, Some(Activation::Sigmoid)) => ConfKind::Sigmoid,
1003        (false, None, _) => ConfKind::Softmax,
1004    };
1005    let low = kernels::confidence_map(data, g, &winners, dq, kind);
1006    match conf.dtype() {
1007        DType::F32 => write_confidence::<f32>(&low, g.width, geometry, conf),
1008        DType::U8 => write_confidence::<u8>(&low, g.width, geometry, conf),
1009        other => Err(DecoderError::NotSupported(format!(
1010            "confidence dtype {other:?}; use f32 or u8"
1011        ))),
1012    }
1013}
1014
1015fn write_classes<T: Score, O: ClassOut + edgefirst_tensor::Element>(
1016    data: &[T],
1017    g: Grid,
1018    cut: f32,
1019    winners: Option<&[u16]>,
1020    geometry: &Geometry,
1021    dst: &mut TensorDyn,
1022) -> DecoderResult<()> {
1023    let mut map = dst
1024        .as_typed_mut::<O>()
1025        .ok_or_else(|| DecoderError::Internal("class map dtype".to_string()))?
1026        .map_for_write()
1027        .map_err(map_err("class map"))?;
1028    let out = map.as_mut_slice();
1029    match (geometry.bilinear_scores(), winners) {
1030        (true, Some(winners)) if g.classes > 1 => {
1031            let (ys, xs) = geometry.bilinear();
1032            kernels::upsample_argmax(data, g, winners, &ys, &xs, out);
1033        }
1034        (true, _) => {
1035            let (ys, xs) = geometry.bilinear();
1036            kernels::upsample_threshold(data, g, cut, &ys, &xs, out);
1037        }
1038        (false, Some(winners)) => {
1039            let (ys, xs) = geometry.nearest();
1040            kernels::upsample_nearest(winners, g.width, &ys, &xs, out, O::from_class);
1041        }
1042        (false, None) => {
1043            return Err(DecoderError::Internal(
1044                "nearest decode without a class map".to_string(),
1045            ))
1046        }
1047    }
1048    Ok(())
1049}
1050
1051fn write_confidence<C: ConfOut + edgefirst_tensor::Element>(
1052    low: &[f32],
1053    width: usize,
1054    geometry: &Geometry,
1055    conf: &mut TensorDyn,
1056) -> DecoderResult<()> {
1057    let mut map = conf
1058        .as_typed_mut::<C>()
1059        .ok_or_else(|| DecoderError::Internal("confidence dtype".to_string()))?
1060        .map_for_write()
1061        .map_err(map_err("confidence"))?;
1062    let out = map.as_mut_slice();
1063    if geometry.bilinear_scores() {
1064        let (ys, xs) = geometry.bilinear();
1065        kernels::upsample_conf_bilinear(low, width, &ys, &xs, out);
1066    } else {
1067        let (ys, xs) = geometry.nearest();
1068        kernels::upsample_nearest(low, width, &ys, &xs, out, C::from_conf);
1069    }
1070    Ok(())
1071}
1072
1073/// Resizes a class map the model already computed; always nearest.
1074fn decode_class_map(
1075    input: &TensorDyn,
1076    config: &SemanticConfig,
1077    geometry: &Geometry,
1078    dst: &mut TensorDyn,
1079) -> DecoderResult<()> {
1080    let g = config.grid_for(input)?;
1081    macro_rules! typed {
1082        ($t:ty) => {{
1083            let map = input
1084                .as_typed::<$t>()
1085                .ok_or_else(|| DecoderError::Internal("class map input dtype".to_string()))?
1086                .map_read()
1087                .map_err(map_err("class map input"))?;
1088            check_extent(map.as_slice().len(), g, "class map input")?;
1089            class_map_into(map.as_slice(), g, geometry, dst)
1090        }};
1091    }
1092    match input.dtype() {
1093        DType::U8 => typed!(u8),
1094        DType::U16 => typed!(u16),
1095        DType::I32 => typed!(i32),
1096        DType::U32 => typed!(u32),
1097        DType::I64 => typed!(i64),
1098        other => Err(DecoderError::NotSupported(format!(
1099            "class map input dtype {other:?}"
1100        ))),
1101    }
1102}
1103
1104fn class_map_into<T>(
1105    data: &[T],
1106    g: Grid,
1107    geometry: &Geometry,
1108    dst: &mut TensorDyn,
1109) -> DecoderResult<()>
1110where
1111    T: Copy + TryInto<u16> + std::fmt::Display,
1112{
1113    let limit = match dst.dtype() {
1114        DType::U8 => u8::CAPACITY,
1115        _ => u16::CAPACITY,
1116    };
1117    let classes = (0..g.height)
1118        .flat_map(|y| (0..g.width).map(move |x| data[y * g.ys + x * g.xs]))
1119        .map(|v| {
1120            v.try_into()
1121                .ok()
1122                .filter(|&c: &u16| (c as usize) < limit)
1123                .ok_or_else(|| {
1124                    DecoderError::InvalidConfig(format!(
1125                        "class index {v} does not fit a {:?} class map",
1126                        dst.dtype()
1127                    ))
1128                })
1129        })
1130        .collect::<DecoderResult<Vec<u16>>>()?;
1131    let (ys, xs) = geometry.nearest();
1132    macro_rules! write {
1133        ($o:ty) => {{
1134            let mut map = dst
1135                .as_typed_mut::<$o>()
1136                .ok_or_else(|| DecoderError::Internal("class map dtype".to_string()))?
1137                .map_for_write()
1138                .map_err(map_err("class map"))?;
1139            kernels::upsample_nearest(
1140                &classes,
1141                geometry.src_w,
1142                &ys,
1143                &xs,
1144                map.as_mut_slice(),
1145                <$o>::from_class,
1146            );
1147        }};
1148    }
1149    match dst.dtype() {
1150        DType::U8 => write!(u8),
1151        _ => write!(u16),
1152    }
1153    Ok(())
1154}