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_letterbox([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 letterbox: Option<[f32; 4]>,
539}
540
541impl SemanticOptions {
542    /// Default options: the model's decode mode, no letterbox.
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 letterbox content region.
554    pub fn with_letterbox(mut self, letterbox: [f32; 4]) -> Self {
555        self.letterbox = Some(letterbox);
556        self
557    }
558}
559
560/// Picks the tensor of a semantic output from a model's outputs: the one
561/// named like the output, else the only one with its shape, else the one at
562/// the output's position when every model output was passed.
563pub fn select_output<'a>(
564    outputs: &[&'a TensorDyn],
565    config: &SemanticConfig,
566) -> DecoderResult<&'a TensorDyn> {
567    let mut found = outputs.iter().copied().filter(|t| config.matches(t));
568    let (first, second) = (found.next(), found.next());
569    if let Some(name) = config.name() {
570        if let Some(t) = outputs
571            .iter()
572            .copied()
573            .find(|t| t.name() == name && config.matches(t))
574        {
575            return Ok(t);
576        }
577    }
578    match (first, second, config.position) {
579        (Some(t), None, _) => Ok(t),
580        (Some(_), Some(_), Some((index, count))) if outputs.len() == count => outputs
581            .get(index)
582            .copied()
583            .filter(|t| config.matches(t))
584            .ok_or_else(|| {
585                DecoderError::InvalidConfig(format!(
586                    "the output at position {index} does not have the semantic output's shape {:?}",
587                    config.shape()
588                ))
589            }),
590        (Some(_), Some(_), _) => Err(DecoderError::InvalidConfig(format!(
591            "several outputs have the semantic output's shape {:?}; pass the outputs in model order or name the tensors",
592            config.shape()
593        ))),
594        (None, _, _) => Err(DecoderError::InvalidShape(format!(
595            "no output has the semantic output's shape {:?}; got {:?}",
596            config.shape(),
597            outputs.iter().map(|t| t.shape().to_vec()).collect::<Vec<_>>()
598        ))),
599    }
600}
601
602/// Decodes a semantic output into `dst`, an `[H, W]` class map of dtype
603/// `u8` (up to 256 classes) or `u16` (up to 65 536).
604///
605/// `confidence`, when given, receives the winning class's probability as an
606/// `[H, W]` map of `f32` in `[0, 1]` or `u8` scaled to 255. It is computed at
607/// the score map's resolution and upsampled, so it is approximate and meant
608/// for overlays. Class-map inputs have no confidence.
609///
610/// # Errors
611///
612/// - [`DecoderError::InvalidShape`] if `input` does not have the configured
613///   shape, or `dst` and `confidence` are not matching `[H, W]` tensors.
614/// - [`DecoderError::NotSupported`] for unsupported dtypes, or when the
615///   class count exceeds what the dtype of `dst` holds.
616/// - [`DecoderError::InvalidConfig`] for an invalid letterbox or
617///   quantization, or a class-map value `dst` cannot hold.
618/// - [`DecoderError::Tensor`] carrying
619///   [`CpuAccessDenied`](edgefirst_tensor::Error::CpuAccessDenied) when
620///   `input` is not declared CPU-readable, or `dst` or `confidence` not
621///   CPU-writable.
622pub fn decode_semantic(
623    input: &TensorDyn,
624    config: &SemanticConfig,
625    dst: &mut TensorDyn,
626    confidence: Option<&mut TensorDyn>,
627    options: &SemanticOptions,
628) -> DecoderResult<()> {
629    if !config.matches(input) {
630        return Err(DecoderError::InvalidShape(format!(
631            "semantic input shape {:?} does not match the configured {:?}",
632            input.shape(),
633            config.shape()
634        )));
635    }
636    let (out_h, out_w) = class_map_dims(dst, "class map")?;
637    if let Some(conf) = confidence.as_deref() {
638        if class_map_dims(conf, "confidence")? != (out_h, out_w) {
639            return Err(DecoderError::InvalidShape(format!(
640                "confidence shape {:?} differs from the class map's {:?}",
641                conf.shape(),
642                dst.shape()
643            )));
644        }
645        if config.input == SemanticInput::ClassMap {
646            return Err(DecoderError::NotSupported(
647                "a class-map output has no confidence".to_string(),
648            ));
649        }
650    }
651    let capacity = match dst.dtype() {
652        DType::U8 => u8::CAPACITY,
653        DType::U16 => u16::CAPACITY,
654        other => {
655            return Err(DecoderError::NotSupported(format!(
656                "class map dtype {other:?}; use u8 or u16"
657            )))
658        }
659    };
660    if config.classes() > capacity {
661        return Err(DecoderError::NotSupported(format!(
662            "{} classes do not fit a {:?} class map",
663            config.classes(),
664            dst.dtype()
665        )));
666    }
667
668    input.cpu_access().require(CpuAccess::Read)?;
669    dst.cpu_access().require(CpuAccess::Write)?;
670    if let Some(conf) = confidence.as_deref() {
671        conf.cpu_access().require(CpuAccess::Write)?;
672    }
673
674    let geometry = Geometry::new(config, options, out_h, out_w)?;
675    match config.input {
676        SemanticInput::ClassMap => decode_class_map(input, config, &geometry, dst),
677        SemanticInput::Scores => decode_scores(input, config, &geometry, dst, confidence),
678    }
679}
680
681/// Validates an `[H, W]` output tensor and returns its dimensions.
682fn class_map_dims(t: &TensorDyn, what: &str) -> DecoderResult<(usize, usize)> {
683    match *t.shape() {
684        [h, w] if h > 0 && w > 0 => {
685            if t.row_stride().is_some_and(|s| s != w * t.dtype().size()) {
686                return Err(DecoderError::NotSupported(format!(
687                    "{what} tensor has a padded row stride"
688                )));
689            }
690            Ok((h, w))
691        }
692        _ => Err(DecoderError::InvalidShape(format!(
693            "{what} tensor shape {:?}; expected [H, W]",
694            t.shape()
695        ))),
696    }
697}
698
699/// Output-to-source sampling resolved from the letterbox and mode.
700struct Geometry {
701    mode: DecodeMode,
702    method: UpsampleMethod,
703    align_corners: bool,
704    roi: [f32; 4],
705    out_h: usize,
706    out_w: usize,
707    src_h: usize,
708    src_w: usize,
709}
710
711impl Geometry {
712    fn new(
713        config: &SemanticConfig,
714        options: &SemanticOptions,
715        out_h: usize,
716        out_w: usize,
717    ) -> DecoderResult<Self> {
718        let [x0, y0, x1, y1] = options.letterbox.unwrap_or([0.0, 0.0, 1.0, 1.0]);
719        let valid =
720            |a: f32, b: f32| a.is_finite() && b.is_finite() && 0.0 <= a && a < b && b <= 1.0;
721        if !valid(x0, x1) || !valid(y0, y1) {
722            return Err(DecoderError::InvalidConfig(format!(
723                "letterbox {:?} must satisfy 0 <= min < max <= 1",
724                [x0, y0, x1, y1]
725            )));
726        }
727        let (src_h, src_w) = (config.height(), config.width());
728        Ok(Self {
729            mode: options.mode.unwrap_or(config.mode),
730            method: config.upsample.method,
731            align_corners: config.upsample.align_corners,
732            roi: [
733                x0 * src_w as f32,
734                y0 * src_h as f32,
735                (x1 - x0) * src_w as f32,
736                (y1 - y0) * src_h as f32,
737            ],
738            out_h,
739            out_w,
740            src_h,
741            src_w,
742        })
743    }
744
745    /// Whether scores are resampled before the argmax or threshold.
746    fn bilinear_scores(&self) -> bool {
747        self.mode == DecodeMode::Accurate && self.method == UpsampleMethod::Bilinear
748    }
749
750    fn nearest(&self) -> (Vec<u32>, Vec<u32>) {
751        let [x0, y0, w, h] = self.roi;
752        (
753            kernels::nearest_taps(self.out_h, self.src_h, y0, h),
754            kernels::nearest_taps(self.out_w, self.src_w, x0, w),
755        )
756    }
757
758    fn bilinear(&self) -> (Vec<kernels::Tap>, Vec<kernels::Tap>) {
759        let [x0, y0, w, h] = self.roi;
760        (
761            kernels::bilinear_taps(self.out_h, self.src_h, y0, h, self.align_corners),
762            kernels::bilinear_taps(self.out_w, self.src_w, x0, w, self.align_corners),
763        )
764    }
765}
766
767/// Real-value map of a score tensor: per-tensor affine, or per-channel
768/// scales that require dequantizing first.
769enum Quant {
770    PerTensor(Dequant),
771    PerChannel {
772        scales: Vec<f32>,
773        zero_points: Vec<i32>,
774    },
775}
776
777/// Position of `axis` among the axes of `shape` longer than 1, so axes of
778/// two shapes that differ only in unit axes compare equal.
779fn squeezed_axis(shape: &[usize], axis: usize) -> Option<usize> {
780    (shape.get(axis)? > &1).then(|| shape[..axis].iter().filter(|&&n| n > 1).count())
781}
782
783/// The real-value map of `input`. `needs_real` marks decodes that compare
784/// against a real threshold or compute a confidence; integer scores without
785/// quantization are then an error rather than taken at face value.
786fn resolve_quant(
787    input: &TensorDyn,
788    config: &SemanticConfig,
789    needs_real: bool,
790) -> DecoderResult<Quant> {
791    if matches!(input.dtype(), DType::F16 | DType::F32) {
792        return Ok(Quant::PerTensor(Dequant::IDENTITY));
793    }
794    let (q, shape) = match (input.quantization(), config.quantization.as_ref()) {
795        (Some(q), _) => (q, input.shape()),
796        (None, Some(q)) => (q, config.shape()),
797        (None, None) if needs_real => {
798            return Err(DecoderError::QuantMissing {
799                dtype: input.dtype(),
800                role: "semantic scores",
801                level: 0,
802            })
803        }
804        (None, None) => return Ok(Quant::PerTensor(Dequant::IDENTITY)),
805    };
806    let check = |s: f32| {
807        if s.is_finite() && s > 0.0 {
808            Ok(s)
809        } else {
810            Err(DecoderError::InvalidConfig(format!(
811                "semantic quantization scale {s} must be positive"
812            )))
813        }
814    };
815    let per_channel = |scales: &[f32], zps: Vec<i32>, axis: usize| {
816        let class_axis = config
817            .layout
818            .classes
819            .and_then(|a| squeezed_axis(config.shape(), a));
820        if class_axis.is_none() || squeezed_axis(shape, axis) != class_axis {
821            return Err(DecoderError::NotSupported(format!(
822                "per-channel quantization on axis {axis}, which is not the class axis"
823            )));
824        }
825        let classes = config.grid().classes;
826        if scales.len() != classes || zps.len() != classes {
827            return Err(DecoderError::InvalidConfig(format!(
828                "per-channel quantization has {} scales and {} zero points for {classes} classes",
829                scales.len(),
830                zps.len()
831            )));
832        }
833        scales.iter().try_for_each(|&s| check(s).map(|_| ()))?;
834        Ok(Quant::PerChannel {
835            scales: scales.to_vec(),
836            zero_points: zps,
837        })
838    };
839    match q.mode() {
840        QuantMode::PerTensorSymmetric { scale } => Ok(Quant::PerTensor(Dequant {
841            scale: check(scale)?,
842            zero_point: 0.0,
843        })),
844        QuantMode::PerTensor { scale, zero_point } => Ok(Quant::PerTensor(Dequant {
845            scale: check(scale)?,
846            zero_point: zero_point as f32,
847        })),
848        QuantMode::PerChannelSymmetric { scales, axis } => {
849            per_channel(scales, vec![0; scales.len()], axis)
850        }
851        QuantMode::PerChannel {
852            scales,
853            zero_points,
854            axis,
855        } => per_channel(scales, zero_points.to_vec(), axis),
856    }
857}
858
859fn map_err(what: &str) -> impl FnOnce(edgefirst_tensor::Error) -> DecoderError + '_ {
860    move |e| {
861        DecoderError::from_map(e, |e| {
862            DecoderError::InvalidConfig(format!("{what} map failed: {e}"))
863        })
864    }
865}
866
867fn decode_scores(
868    input: &TensorDyn,
869    config: &SemanticConfig,
870    geometry: &Geometry,
871    dst: &mut TensorDyn,
872    confidence: Option<&mut TensorDyn>,
873) -> DecoderResult<()> {
874    let g = config.grid_for(input)?;
875    let needs_real = g.classes == 1 || confidence.is_some();
876    macro_rules! typed {
877        ($t:ty) => {{
878            let map = input
879                .as_typed::<$t>()
880                .ok_or_else(|| DecoderError::Internal("semantic input dtype".to_string()))?
881                .map_read()
882                .map_err(map_err("semantic input"))?;
883            check_extent(map.as_slice().len(), g, "semantic input")?;
884            match resolve_quant(input, config, needs_real)? {
885                Quant::PerTensor(dq) => {
886                    scores_into(map.as_slice(), g, config, dq, geometry, dst, confidence)
887                }
888                Quant::PerChannel {
889                    scales,
890                    zero_points,
891                } => {
892                    let real = dequantize_per_channel(map.as_slice(), g, &scales, &zero_points);
893                    scores_into(
894                        &real,
895                        g,
896                        config,
897                        Dequant::IDENTITY,
898                        geometry,
899                        dst,
900                        confidence,
901                    )
902                }
903            }
904        }};
905    }
906    match input.dtype() {
907        DType::F32 => typed!(f32),
908        DType::F16 => typed!(f16),
909        DType::U8 => typed!(u8),
910        DType::I8 => typed!(i8),
911        DType::U16 => typed!(u16),
912        DType::I16 => typed!(i16),
913        other => Err(DecoderError::NotSupported(format!(
914            "semantic score dtype {other:?}"
915        ))),
916    }
917}
918
919fn dequantize_per_channel<T: Score>(
920    data: &[T],
921    g: Grid,
922    scales: &[f32],
923    zero_points: &[i32],
924) -> Vec<f32> {
925    let mut out = vec![0f32; data.len()];
926    for (c, (&s, &z)) in scales.iter().zip(zero_points).enumerate() {
927        let z = z as f32;
928        for y in 0..g.height {
929            for x in 0..g.width {
930                let i = c * g.cs + y * g.ys + x * g.xs;
931                out[i] = (data[i].to_f32() - z) * s;
932            }
933        }
934    }
935    out
936}
937
938fn scores_into<T: Score>(
939    data: &[T],
940    g: Grid,
941    config: &SemanticConfig,
942    dq: Dequant,
943    geometry: &Geometry,
944    dst: &mut TensorDyn,
945    confidence: Option<&mut TensorDyn>,
946) -> DecoderResult<()> {
947    let single = g.classes == 1;
948    let cut = dq.stored(config.threshold());
949    // The low-resolution winners feed the fast path, the accurate early-out
950    // and the confidence map; single-channel accurate decodes need them only
951    // for confidence.
952    let needs_winners = !single || !geometry.bilinear_scores() || confidence.is_some();
953    let winners = needs_winners.then(|| {
954        if single {
955            kernels::threshold_map(data, g, cut)
956        } else {
957            kernels::argmax_map(data, g)
958        }
959    });
960    match dst.dtype() {
961        DType::U8 => write_classes::<T, u8>(data, g, cut, winners.as_deref(), geometry, dst)?,
962        _ => write_classes::<T, u16>(data, g, cut, winners.as_deref(), geometry, dst)?,
963    }
964    let (Some(conf), Some(winners)) = (confidence, winners) else {
965        return Ok(());
966    };
967    let kind = match (single, config.activation, config.activation_required) {
968        (true, Some(_), _) => ConfKind::BinaryProbability,
969        (true, None, _) => ConfKind::BinaryLogit,
970        (false, Some(_), _) => ConfKind::Probability,
971        (false, None, Some(Activation::Sigmoid)) => ConfKind::Sigmoid,
972        (false, None, _) => ConfKind::Softmax,
973    };
974    let low = kernels::confidence_map(data, g, &winners, dq, kind);
975    match conf.dtype() {
976        DType::F32 => write_confidence::<f32>(&low, g.width, geometry, conf),
977        DType::U8 => write_confidence::<u8>(&low, g.width, geometry, conf),
978        other => Err(DecoderError::NotSupported(format!(
979            "confidence dtype {other:?}; use f32 or u8"
980        ))),
981    }
982}
983
984fn write_classes<T: Score, O: ClassOut + edgefirst_tensor::Element>(
985    data: &[T],
986    g: Grid,
987    cut: f32,
988    winners: Option<&[u16]>,
989    geometry: &Geometry,
990    dst: &mut TensorDyn,
991) -> DecoderResult<()> {
992    let mut map = dst
993        .as_typed_mut::<O>()
994        .ok_or_else(|| DecoderError::Internal("class map dtype".to_string()))?
995        .map_for_write()
996        .map_err(map_err("class map"))?;
997    let out = map.as_mut_slice();
998    match (geometry.bilinear_scores(), winners) {
999        (true, Some(winners)) if g.classes > 1 => {
1000            let (ys, xs) = geometry.bilinear();
1001            kernels::upsample_argmax(data, g, winners, &ys, &xs, out);
1002        }
1003        (true, _) => {
1004            let (ys, xs) = geometry.bilinear();
1005            kernels::upsample_threshold(data, g, cut, &ys, &xs, out);
1006        }
1007        (false, Some(winners)) => {
1008            let (ys, xs) = geometry.nearest();
1009            kernels::upsample_nearest(winners, g.width, &ys, &xs, out, O::from_class);
1010        }
1011        (false, None) => {
1012            return Err(DecoderError::Internal(
1013                "nearest decode without a class map".to_string(),
1014            ))
1015        }
1016    }
1017    Ok(())
1018}
1019
1020fn write_confidence<C: ConfOut + edgefirst_tensor::Element>(
1021    low: &[f32],
1022    width: usize,
1023    geometry: &Geometry,
1024    conf: &mut TensorDyn,
1025) -> DecoderResult<()> {
1026    let mut map = conf
1027        .as_typed_mut::<C>()
1028        .ok_or_else(|| DecoderError::Internal("confidence dtype".to_string()))?
1029        .map_for_write()
1030        .map_err(map_err("confidence"))?;
1031    let out = map.as_mut_slice();
1032    if geometry.bilinear_scores() {
1033        let (ys, xs) = geometry.bilinear();
1034        kernels::upsample_conf_bilinear(low, width, &ys, &xs, out);
1035    } else {
1036        let (ys, xs) = geometry.nearest();
1037        kernels::upsample_nearest(low, width, &ys, &xs, out, C::from_conf);
1038    }
1039    Ok(())
1040}
1041
1042/// Resizes a class map the model already computed; always nearest.
1043fn decode_class_map(
1044    input: &TensorDyn,
1045    config: &SemanticConfig,
1046    geometry: &Geometry,
1047    dst: &mut TensorDyn,
1048) -> DecoderResult<()> {
1049    let g = config.grid_for(input)?;
1050    macro_rules! typed {
1051        ($t:ty) => {{
1052            let map = input
1053                .as_typed::<$t>()
1054                .ok_or_else(|| DecoderError::Internal("class map input dtype".to_string()))?
1055                .map_read()
1056                .map_err(map_err("class map input"))?;
1057            check_extent(map.as_slice().len(), g, "class map input")?;
1058            class_map_into(map.as_slice(), g, geometry, dst)
1059        }};
1060    }
1061    match input.dtype() {
1062        DType::U8 => typed!(u8),
1063        DType::U16 => typed!(u16),
1064        DType::I32 => typed!(i32),
1065        DType::U32 => typed!(u32),
1066        DType::I64 => typed!(i64),
1067        other => Err(DecoderError::NotSupported(format!(
1068            "class map input dtype {other:?}"
1069        ))),
1070    }
1071}
1072
1073fn class_map_into<T>(
1074    data: &[T],
1075    g: Grid,
1076    geometry: &Geometry,
1077    dst: &mut TensorDyn,
1078) -> DecoderResult<()>
1079where
1080    T: Copy + TryInto<u16> + std::fmt::Display,
1081{
1082    let limit = match dst.dtype() {
1083        DType::U8 => u8::CAPACITY,
1084        _ => u16::CAPACITY,
1085    };
1086    let classes = (0..g.height)
1087        .flat_map(|y| (0..g.width).map(move |x| data[y * g.ys + x * g.xs]))
1088        .map(|v| {
1089            v.try_into()
1090                .ok()
1091                .filter(|&c: &u16| (c as usize) < limit)
1092                .ok_or_else(|| {
1093                    DecoderError::InvalidConfig(format!(
1094                        "class index {v} does not fit a {:?} class map",
1095                        dst.dtype()
1096                    ))
1097                })
1098        })
1099        .collect::<DecoderResult<Vec<u16>>>()?;
1100    let (ys, xs) = geometry.nearest();
1101    macro_rules! write {
1102        ($o:ty) => {{
1103            let mut map = dst
1104                .as_typed_mut::<$o>()
1105                .ok_or_else(|| DecoderError::Internal("class map dtype".to_string()))?
1106                .map_for_write()
1107                .map_err(map_err("class map"))?;
1108            kernels::upsample_nearest(
1109                &classes,
1110                geometry.src_w,
1111                &ys,
1112                &xs,
1113                map.as_mut_slice(),
1114                <$o>::from_class,
1115            );
1116        }};
1117    }
1118    match dst.dtype() {
1119        DType::U8 => write!(u8),
1120        _ => write!(u16),
1121    }
1122    Ok(())
1123}