Skip to main content

edgefirst_image/
options.rs

1// SPDX-FileCopyrightText: Copyright 2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Convert options: geometry (rotation, flip, crop, letterbox placement and
5//! pad) and the pixel value mapping (normalization, output quantization).
6
7use crate::{Crop, Error, Flip, Region, Result, Rotation};
8use edgefirst_tensor::{DType, PixelFormat, QuantMode, Quantization};
9
10edgefirst_tensor::ef_vocabulary! {
11    /// Where a letterboxed image sits inside the destination. The code is the
12    /// one the C `EF_LETTERBOX_PLACEMENT_*` enumerators and the Python
13    /// `LetterboxPlacement` enum use.
14    #[derive(Default)]
15    pub enum LetterboxPlacement {
16        /// Centred, padding split between both sides (the default).
17        #[default]
18        Center = 0, "center", CENTER,
19        /// Anchored at the top-left corner, padding on the right and bottom.
20        TopLeft = 1, "top_left", TOP_LEFT,
21    }
22    #[doc(hidden)]
23    pub mod letterbox_placement_code;
24}
25
26edgefirst_tensor::ef_vocabulary! {
27    /// A named [`Normalization`]. The code is the one the C
28    /// `EF_NORMALIZATION_*` enumerators and the Python `Normalization` enum
29    /// use; the names match the `edgefirst.json` presets.
30    #[derive(Default)]
31    pub enum NormalizationPreset {
32        /// `x / 255`, giving `[0, 1]` (the default).
33        #[default]
34        Unsigned = 0, "unsigned", UNSIGNED,
35        /// `x / 127.5 - 1`, giving `[-1, 1]`.
36        Signed = 1, "signed", SIGNED,
37        /// `x`, giving `[0, 255]`.
38        Raw = 2, "raw", RAW,
39        /// ImageNet mean and standard deviation.
40        Imagenet = 3, "imagenet", IMAGENET,
41    }
42    #[doc(hidden)]
43    pub mod normalization_code;
44}
45
46const IMAGENET_MEAN: [f32; 3] = [123.675, 116.28, 103.53];
47const IMAGENET_STD: [f32; 3] = [58.395, 57.12, 57.375];
48
49/// Per-channel `(x - mean) / std` on 0–255 pixel values, in RGB order.
50///
51/// It applies to the colour channels of the destination; an alpha channel
52/// always maps as [`NormalizationPreset::Unsigned`]. A `Grey` destination uses
53/// the first channel's coefficients.
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct Normalization {
56    mean: [f32; 3],
57    std: [f32; 3],
58}
59
60impl Default for Normalization {
61    fn default() -> Self {
62        Self::preset(NormalizationPreset::Unsigned)
63    }
64}
65
66impl From<NormalizationPreset> for Normalization {
67    fn from(p: NormalizationPreset) -> Self {
68        Self::preset(p)
69    }
70}
71
72impl Normalization {
73    /// The coefficients of a named preset.
74    pub const fn preset(p: NormalizationPreset) -> Self {
75        let (mean, std) = match p {
76            NormalizationPreset::Unsigned => ([0.0; 3], [255.0; 3]),
77            NormalizationPreset::Signed => ([127.5; 3], [127.5; 3]),
78            NormalizationPreset::Raw => ([0.0; 3], [1.0; 3]),
79            NormalizationPreset::Imagenet => (IMAGENET_MEAN, IMAGENET_STD),
80        };
81        Self { mean, std }
82    }
83
84    /// `x / 255`.
85    pub const fn unsigned() -> Self {
86        Self::preset(NormalizationPreset::Unsigned)
87    }
88
89    /// `x / 127.5 - 1`.
90    pub const fn signed() -> Self {
91        Self::preset(NormalizationPreset::Signed)
92    }
93
94    /// `x` unchanged.
95    pub const fn raw() -> Self {
96        Self::preset(NormalizationPreset::Raw)
97    }
98
99    /// ImageNet mean `[123.675, 116.28, 103.53]`, std `[58.395, 57.12, 57.375]`.
100    pub const fn imagenet() -> Self {
101        Self::preset(NormalizationPreset::Imagenet)
102    }
103
104    /// Explicit coefficients: one value (every channel) or three (RGB).
105    /// `std` must be finite and non-zero, `mean` finite.
106    pub fn mean_std(mean: &[f32], std: &[f32]) -> Result<Self> {
107        let mean = broadcast3("mean", mean)?;
108        let std = broadcast3("std", std)?;
109        if mean.iter().any(|m| !m.is_finite()) {
110            return Err(Error::InvalidOptions(format!(
111                "normalization mean {mean:?} must be finite"
112            )));
113        }
114        if std.iter().any(|s| !s.is_finite() || *s == 0.0) {
115            return Err(Error::InvalidOptions(format!(
116                "normalization std {std:?} must be finite and non-zero"
117            )));
118        }
119        Ok(Self { mean, std })
120    }
121
122    /// The per-channel mean, RGB order.
123    pub fn mean(&self) -> [f32; 3] {
124        self.mean
125    }
126
127    /// The per-channel standard deviation, RGB order.
128    pub fn std(&self) -> [f32; 3] {
129        self.std
130    }
131}
132
133fn broadcast3(name: &str, v: &[f32]) -> Result<[f32; 3]> {
134    match v {
135        [x] => Ok([*x; 3]),
136        [r, g, b] => Ok([*r, *g, *b]),
137        _ => Err(Error::InvalidOptions(format!(
138            "normalization {name} needs 1 or 3 values, got {}",
139            v.len()
140        ))),
141    }
142}
143
144/// Options for [`convert`](crate::ImageProcessorTrait::convert). The default
145/// converts the whole source, stretched, with no rotation or flip, and maps
146/// pixels as [`Normalization::unsigned`] for float destinations.
147///
148/// ```
149/// # use edgefirst_image::{ConvertOptions, LetterboxPlacement, Normalization, Rotation};
150/// let opts = ConvertOptions::new()
151///     .letterbox([0, 0, 0, 255])
152///     .placement(LetterboxPlacement::TopLeft)
153///     .pad_output([0.0; 4])
154///     .normalization(Normalization::imagenet())
155///     .rotation(Rotation::None);
156/// # let _ = opts;
157/// ```
158#[derive(Debug, Clone, Copy, PartialEq, Default)]
159pub struct ConvertOptions {
160    rotation: Rotation,
161    flip: Flip,
162    crop: Crop,
163    placement: LetterboxPlacement,
164    pad_output: Option<[f32; 4]>,
165    normalization: Normalization,
166}
167
168impl ConvertOptions {
169    /// The default options.
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    /// Rotate the output, in 90° steps.
175    pub fn rotation(mut self, rotation: Rotation) -> Self {
176        self.rotation = rotation;
177        self
178    }
179
180    /// Flip the output.
181    pub fn flip(mut self, flip: Flip) -> Self {
182        self.flip = flip;
183        self
184    }
185
186    /// Set the source region and fit together.
187    pub fn crop(mut self, crop: Crop) -> Self {
188        self.crop = crop;
189        self
190    }
191
192    /// Sample only `source` of the input; `None` samples all of it.
193    pub fn source(mut self, source: Option<Region>) -> Self {
194        self.crop.source = source;
195        self
196    }
197
198    /// Letterbox: keep the source aspect ratio and pad the rest with the
199    /// RGBA pixel `pad`, which is mapped like the image.
200    pub fn letterbox(mut self, pad: [u8; 4]) -> Self {
201        self.crop.fit = crate::Fit::Letterbox { pad };
202        self
203    }
204
205    /// Where a letterboxed image sits. Ignored without a letterbox fit.
206    pub fn placement(mut self, placement: LetterboxPlacement) -> Self {
207        self.placement = placement;
208        self
209    }
210
211    /// Pad with these values as written to the destination (after
212    /// normalization; quantized for integer destinations), instead of mapping
213    /// the pixel pad. Ignored without a letterbox fit.
214    pub fn pad_output(mut self, pad: [f32; 4]) -> Self {
215        self.pad_output = Some(pad);
216        self
217    }
218
219    /// How pixel values map to the destination.
220    pub fn normalization(mut self, normalization: impl Into<Normalization>) -> Self {
221        self.normalization = normalization.into();
222        self
223    }
224
225    /// The output rotation.
226    pub fn get_rotation(&self) -> Rotation {
227        self.rotation
228    }
229
230    /// The output flip.
231    pub fn get_flip(&self) -> Flip {
232        self.flip
233    }
234
235    /// The source region and fit.
236    pub fn get_crop(&self) -> Crop {
237        self.crop
238    }
239
240    /// The letterbox placement.
241    pub fn get_placement(&self) -> LetterboxPlacement {
242        self.placement
243    }
244
245    /// The output-domain pad, when set.
246    pub fn get_pad_output(&self) -> Option<[f32; 4]> {
247        self.pad_output
248    }
249
250    /// The pixel value mapping.
251    pub fn get_normalization(&self) -> Normalization {
252        self.normalization
253    }
254}
255
256#[cfg(feature = "decode")]
257impl ConvertOptions {
258    /// Apply a model's `input.preprocessing` from `edgefirst.json`:
259    /// normalization, letterbox fit, placement and pad. `resize: stretch`
260    /// clears a letterbox; with no `resize` and no `letterbox` the fit is left
261    /// as it is. `interpolation` is a processor setting
262    /// ([`ImageProcessor::set_interpolation`](crate::ImageProcessor)) and is
263    /// not read here.
264    pub fn with_preprocessing(
265        mut self,
266        pre: &edgefirst_decoder::schema::Preprocessing,
267    ) -> Result<Self> {
268        use edgefirst_decoder::schema::{LetterboxPlacement as P, PadSpace, ResizeMode};
269        if let Some(n) = &pre.normalization {
270            let (mean, std) = n
271                .mean_std(3)
272                .map_err(|e| Error::InvalidOptions(e.to_string()))?;
273            self.normalization = Normalization::mean_std(&mean, &std)?;
274        }
275        let letterbox = match pre.resize {
276            Some(ResizeMode::Stretch) => false,
277            Some(ResizeMode::Letterbox) => true,
278            Some(other) => {
279                return Err(Error::NotSupported(format!("resize mode {other:?}")));
280            }
281            None => pre.letterbox.is_some(),
282        };
283        if pre.resize == Some(ResizeMode::Stretch) {
284            self.crop.fit = crate::Fit::Stretch;
285        }
286        if !letterbox {
287            return Ok(self);
288        }
289        let lb = pre.letterbox.clone().unwrap_or_default();
290        self.placement = match lb.placement {
291            None | Some(P::Center) => LetterboxPlacement::Center,
292            Some(P::TopLeft) => LetterboxPlacement::TopLeft,
293            Some(other) => {
294                return Err(Error::NotSupported(format!(
295                    "letterbox placement {other:?}"
296                )));
297            }
298        };
299        let pad: [f32; 3] = match lb.pad.as_deref() {
300            None => [114.0; 3],
301            Some(p) => broadcast3("letterbox pad", p)?,
302        };
303        match lb.pad_space {
304            None | Some(PadSpace::Pixel) => {
305                let [r, g, b] = pad.map(|v| v.round().clamp(0.0, 255.0) as u8);
306                self.crop.fit = crate::Fit::Letterbox {
307                    pad: [r, g, b, 255],
308                };
309                self.pad_output = None;
310            }
311            Some(PadSpace::Normalized) => {
312                self.crop.fit = crate::Fit::Letterbox {
313                    pad: [0, 0, 0, 255],
314                };
315                self.pad_output = Some([pad[0], pad[1], pad[2], 1.0]);
316            }
317            Some(other) => {
318                return Err(Error::NotSupported(format!(
319                    "letterbox pad space {other:?}"
320                )));
321            }
322        }
323        Ok(self)
324    }
325}
326
327/// The output mapping a convert must apply beyond the plain one, or `None`
328/// when the plain mapping is exact. `letterboxed` says whether the resolved
329/// crop pads the destination.
330pub(crate) fn output_map_for(
331    options: &ConvertOptions,
332    dst: &edgefirst_tensor::TensorDyn,
333    letterboxed: bool,
334) -> Result<Option<(OutputMap, Option<[f32; 4]>)>> {
335    let fmt = dst.format().ok_or(Error::NotAnImage)?;
336    let mappable = matches!(
337        fmt.layout(),
338        edgefirst_tensor::PixelLayout::Packed | edgefirst_tensor::PixelLayout::Planar
339    ) && !fmt.is_yuv();
340    let pad_output = options.pad_output.filter(|_| letterboxed);
341    if !mappable {
342        if options.normalization != Normalization::default() || pad_output.is_some() {
343            return Err(Error::NotSupported(format!(
344                "normalization and output-domain padding do not apply to {fmt:?}"
345            )));
346        }
347        return Ok(None);
348    }
349    let map = OutputMap::resolve(&options.normalization, dst.dtype(), fmt, dst.quantization())?;
350    if map.is_default && pad_output.is_none() {
351        return Ok(None);
352    }
353    Ok(Some((map, pad_output)))
354}
355
356/// How a destination element is produced from an 8-bit source sample `x`,
357/// per destination channel `c`:
358///
359/// - float destinations: `x * scale[c] + offset[c]`;
360/// - integer destinations: `clamp(floor(x * scale[c] + offset[c] + 0.5) +
361///   zero_point[c], lo[c], hi[c])`, stored as its low byte.
362///
363/// Colour channels carry the normalization (and quantization). Alpha maps to
364/// `x / 255` for float destinations and stays the raw byte for integer ones,
365/// as a plain convert always has.
366#[derive(Debug, Clone, Copy, PartialEq)]
367pub(crate) struct OutputMap {
368    /// Per destination channel, in the destination's channel order.
369    pub(crate) scale: [f32; 4],
370    pub(crate) offset: [f32; 4],
371    pub(crate) zero_point: [f32; 4],
372    /// `Some([(lo, hi); 4])` for integer destinations.
373    pub(crate) bounds: Option<[(f32, f32); 4]>,
374    /// Integer destinations: the quantization scale per channel, which an
375    /// output-domain pad is divided by.
376    pub(crate) quant_scale: [f32; 4],
377    /// The mapping a plain convert has always applied for this dtype.
378    pub(crate) is_default: bool,
379}
380
381impl OutputMap {
382    /// Resolve the mapping for a destination of `dtype` and `format`, with
383    /// optional quantization. Integer destinations without quantization use
384    /// scale 1/255 and zero point 0 (u8) or -128 (i8), which reproduces a
385    /// plain u8 copy and the i8 `x ^ 0x80` bias.
386    pub(crate) fn resolve(
387        normalization: &Normalization,
388        dtype: DType,
389        format: PixelFormat,
390        quantization: Option<&Quantization>,
391    ) -> Result<Self> {
392        let plain = *normalization == Normalization::default();
393        let order = channel_order(format);
394        let (lo, hi, implicit_zp) = match dtype {
395            DType::F32 | DType::F16 => {
396                let (scale, offset) = channel_affine(normalization, order, [1.0; 4]);
397                return Ok(Self {
398                    scale,
399                    offset,
400                    zero_point: [0.0; 4],
401                    bounds: None,
402                    quant_scale: [1.0; 4],
403                    is_default: plain,
404                });
405            }
406            DType::U8 => (0.0, 255.0, 0),
407            DType::I8 => (-128.0, 127.0, -128),
408            other => {
409                return Err(Error::NotSupported(format!(
410                    "convert does not write {other:?} destinations"
411                )))
412            }
413        };
414        let (qscale, qzp) = match quantization {
415            None if plain => ([1.0 / 255.0; 4], [implicit_zp as f32; 4]),
416            None => {
417                return Err(Error::InvalidOptions(format!(
418                    "a {dtype:?} destination needs quantization to apply a \
419                     non-default normalization"
420                )))
421            }
422            Some(q) => quant_per_channel(q, format, format.channels())?,
423        };
424        let (mut scale, mut offset) = channel_affine(normalization, order, qscale);
425        let mut zero_point = qzp;
426        let mut quant_scale = qscale;
427        let mut bounds = [(lo, hi); 4];
428        for (c, &src) in order.iter().enumerate() {
429            if src == 3 {
430                (scale[c], offset[c], zero_point[c], quant_scale[c]) = (1.0, 0.0, 0.0, 1.0);
431                bounds[c] = (0.0, 255.0);
432            }
433        }
434        let is_default = plain
435            && qscale.iter().all(|s| *s == 1.0 / 255.0)
436            && qzp.iter().all(|z| *z == implicit_zp as f32);
437        Ok(Self {
438            scale,
439            offset,
440            zero_point,
441            bounds: Some(bounds),
442            quant_scale,
443            is_default,
444        })
445    }
446
447    /// Map an 8-bit sample on channel `c`.
448    pub(crate) fn apply(&self, c: usize, x: u8) -> f32 {
449        let y = x as f32 * self.scale[c] + self.offset[c];
450        match self.bounds {
451            Some(b) => ((y + 0.5).floor() + self.zero_point[c]).clamp(b[c].0, b[c].1),
452            None => y,
453        }
454    }
455
456    /// The destination value for an output-domain value `v` on channel `c`.
457    pub(crate) fn apply_output(&self, c: usize, v: f32) -> f32 {
458        match self.bounds {
459            Some(b) => {
460                ((v / self.quant_scale[c] + 0.5).floor() + self.zero_point[c]).clamp(b[c].0, b[c].1)
461            }
462            None => v,
463        }
464    }
465
466    /// The 8-bit sample that maps to exactly `target` on integer channel `c`,
467    /// preferring the one whose unrounded value sits farthest from a rounding
468    /// tie; `None` when no sample reaches it.
469    #[cfg_attr(not(feature = "opengl"), allow(dead_code))]
470    pub(crate) fn sample_for(&self, c: usize, target: f32) -> Option<u8> {
471        self.bounds?;
472        (0..=255u8)
473            .filter(|&x| self.apply(c, x) == target)
474            .min_by(|&a, &b| {
475                let off = |x: u8| {
476                    let y = x as f32 * self.scale[c] + self.offset[c];
477                    ((y - y.floor()) - 0.5).abs()
478                };
479                off(b).total_cmp(&off(a))
480            })
481    }
482
483    /// The destination values of the letterbox pad: `pad_output` as given,
484    /// else the pixel `pad` mapped like the image.
485    pub(crate) fn pad_values(&self, pad: [u8; 4], pad_output: Option<[f32; 4]>) -> [f32; 4] {
486        std::array::from_fn(|c| match pad_output {
487            Some(p) => self.apply_output(c, p[c]),
488            None => self.apply(c, pad[c]),
489        })
490    }
491}
492
493/// The destination's colour channel order as indices into RGB, with `3` for
494/// alpha.
495fn channel_order(format: PixelFormat) -> [usize; 4] {
496    match format {
497        PixelFormat::Bgra => [2, 1, 0, 3],
498        PixelFormat::Grey => [0, 3, 3, 3],
499        _ => [0, 1, 2, 3],
500    }
501}
502
503/// Fold normalization and the quantization scale into one per-channel affine
504/// on 8-bit samples, in the destination's channel order. Alpha maps as
505/// `x / 255` before quantization.
506fn channel_affine(n: &Normalization, order: [usize; 4], qscale: [f32; 4]) -> ([f32; 4], [f32; 4]) {
507    let mut scale = [0.0; 4];
508    let mut offset = [0.0; 4];
509    for (c, &src) in order.iter().enumerate() {
510        let (mean, std) = if src == 3 {
511            (0.0, 255.0)
512        } else {
513            (n.mean[src] as f64, n.std[src] as f64)
514        };
515        let qs = qscale[c] as f64;
516        scale[c] = (1.0 / (std * qs)) as f32;
517        offset[c] = (-mean / (std * qs)) as f32;
518    }
519    (scale, offset)
520}
521
522/// Per destination channel scale and zero point.
523fn quant_per_channel(
524    q: &Quantization,
525    format: PixelFormat,
526    channels: usize,
527) -> Result<([f32; 4], [f32; 4])> {
528    let per_tensor = |s: f32, z: i32| ([s; 4], [z as f32; 4]);
529    let (s, z) = match q.mode() {
530        QuantMode::PerTensorSymmetric { scale } => per_tensor(scale, 0),
531        QuantMode::PerTensor { scale, zero_point } => per_tensor(scale, zero_point),
532        QuantMode::PerChannelSymmetric { scales, .. } if scales.len() == channels => {
533            let mut s = [scales[0]; 4];
534            s[..channels].copy_from_slice(scales);
535            (s, [0.0; 4])
536        }
537        QuantMode::PerChannel {
538            scales,
539            zero_points,
540            ..
541        } if scales.len() == channels => {
542            let mut s = [scales[0]; 4];
543            let mut z = [zero_points[0] as f32; 4];
544            s[..channels].copy_from_slice(scales);
545            for (d, zp) in z.iter_mut().zip(zero_points) {
546                *d = *zp as f32;
547            }
548            (s, z)
549        }
550        _ => {
551            return Err(Error::InvalidOptions(format!(
552                "{format:?} destination quantization must be per-tensor or \
553                 have one scale per channel ({channels})"
554            )))
555        }
556    };
557    if s.iter().any(|v| !v.is_finite() || *v <= 0.0) {
558        return Err(Error::InvalidOptions(format!(
559            "destination quantization scale {s:?} must be positive and finite"
560        )));
561    }
562    Ok((s, z))
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn default_float_map_is_divide_by_255() {
571        let m = OutputMap::resolve(
572            &Normalization::default(),
573            DType::F32,
574            PixelFormat::Rgb,
575            None,
576        )
577        .unwrap();
578        assert!(m.is_default);
579        for x in [0u8, 1, 128, 255] {
580            assert_eq!(m.apply(0, x), x as f32 / 255.0);
581        }
582    }
583
584    #[test]
585    fn implicit_int_quantization_reproduces_plain_converts() {
586        let u = OutputMap::resolve(&Normalization::default(), DType::U8, PixelFormat::Rgb, None)
587            .unwrap();
588        let i = OutputMap::resolve(&Normalization::default(), DType::I8, PixelFormat::Rgb, None)
589            .unwrap();
590        assert!(u.is_default && i.is_default);
591        for x in 0..=255u8 {
592            assert_eq!(u.apply(1, x), x as f32);
593            assert_eq!(i.apply(1, x) as i8 as u8, x ^ 0x80);
594        }
595    }
596
597    #[test]
598    fn imagenet_matches_reference_in_bgra_order() {
599        let n = Normalization::imagenet();
600        let m = OutputMap::resolve(&n, DType::F32, PixelFormat::Bgra, None).unwrap();
601        assert!(!m.is_default);
602        let want_b = (200.0 - 103.53) / 57.375;
603        assert!((m.apply(0, 200) - want_b).abs() < 1e-5);
604        assert!((m.apply(3, 255) - 1.0).abs() < 1e-6, "alpha stays unsigned");
605    }
606
607    #[test]
608    fn int_normalization_needs_quantization() {
609        let e = OutputMap::resolve(&Normalization::signed(), DType::I8, PixelFormat::Rgb, None);
610        assert!(matches!(e, Err(Error::InvalidOptions(_))));
611        let q = Quantization::per_tensor(1.0 / 128.0, 0);
612        let m = OutputMap::resolve(
613            &Normalization::signed(),
614            DType::I8,
615            PixelFormat::Rgb,
616            Some(&q),
617        )
618        .unwrap();
619        assert_eq!(m.apply(0, 0), -128.0);
620        assert_eq!(m.apply(0, 255), 127.0, "128 clamps to the i8 range");
621        assert_eq!(m.apply(0, 191), 64.0);
622    }
623
624    #[test]
625    fn mean_std_broadcasts_and_validates() {
626        assert_eq!(
627            Normalization::mean_std(&[0.0], &[255.0]).unwrap(),
628            Normalization::unsigned()
629        );
630        assert!(Normalization::mean_std(&[0.0, 0.0], &[1.0]).is_err());
631        assert!(Normalization::mean_std(&[0.0], &[0.0]).is_err());
632        assert!(Normalization::mean_std(&[f32::NAN], &[1.0]).is_err());
633    }
634
635    #[test]
636    fn presets_parse_from_their_names() {
637        for &p in NormalizationPreset::all() {
638            assert_eq!(NormalizationPreset::from_str_code(p.as_str()), Some(p));
639        }
640        assert_eq!(
641            LetterboxPlacement::from_str_code("top_left"),
642            Some(LetterboxPlacement::TopLeft)
643        );
644    }
645
646    #[cfg(feature = "decode")]
647    #[test]
648    fn segformer_preprocessing_maps_to_options() {
649        let pre: edgefirst_decoder::schema::Preprocessing = serde_json::from_str(
650            r#"{"normalization": "imagenet", "resize": "letterbox",
651                "letterbox": {"placement": "top_left", "pad": [0.0], "pad_space": "normalized"}}"#,
652        )
653        .unwrap();
654        let o = ConvertOptions::new().with_preprocessing(&pre).unwrap();
655        assert_eq!(o.get_normalization(), Normalization::imagenet());
656        assert_eq!(o.get_placement(), LetterboxPlacement::TopLeft);
657        assert_eq!(o.get_pad_output(), Some([0.0, 0.0, 0.0, 1.0]));
658        assert!(matches!(o.get_crop().fit, crate::Fit::Letterbox { .. }));
659
660        let pre: edgefirst_decoder::schema::Preprocessing =
661            serde_json::from_str(r#"{"resize": "letterbox"}"#).unwrap();
662        let o = ConvertOptions::new().with_preprocessing(&pre).unwrap();
663        assert_eq!(
664            o.get_crop().fit,
665            crate::Fit::Letterbox {
666                pad: [114, 114, 114, 255]
667            }
668        );
669        assert_eq!(o.get_normalization(), Normalization::unsigned());
670    }
671
672    #[test]
673    fn an_output_pad_has_an_equivalent_sample_when_reachable() {
674        let q = Quantization::per_tensor(0.0187, -14);
675        let m = OutputMap::resolve(
676            &Normalization::imagenet(),
677            DType::I8,
678            PixelFormat::Rgb,
679            Some(&q),
680        )
681        .unwrap();
682        let pad = m.pad_values([0; 4], Some([0.0; 4]));
683        for (c, &want) in pad.iter().enumerate().take(3) {
684            let x = m.sample_for(c, want).expect("0.0 is reachable");
685            assert_eq!(m.apply(c, x), want);
686        }
687        // A coarse quantization skips values: 10.0 / 0.5 is not reachable at
688        // scale 1/255 per step 0.5.
689        let q = Quantization::per_tensor(1.0 / 512.0, 0);
690        let m = OutputMap::resolve(
691            &Normalization::unsigned(),
692            DType::U8,
693            PixelFormat::Rgb,
694            Some(&q),
695        )
696        .unwrap();
697        assert_eq!(m.sample_for(0, 1.0), None);
698    }
699}