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), resampling, and the pixel value mapping (normalization, output
6//! quantization).
7
8use crate::{Crop, Error, Flip, Region, Result, Rotation};
9use edgefirst_tensor::{
10    ContentRegion, ContentRegionSource, DType, FitMode, Interpolation, LetterboxPlacement,
11    Normalization, PadSpace, PixelFormat, PreprocessSpec, QuantMode, Quantization,
12};
13
14/// Options for [`convert`](crate::ImageProcessorTrait::convert). The default
15/// converts the whole source, stretched, with no rotation or flip, resamples
16/// with the processor's interpolation, and maps pixels as
17/// [`Normalization::unsigned`] for float destinations.
18///
19/// ```
20/// # use edgefirst_image::{ConvertOptions, LetterboxPlacement, Normalization, Rotation};
21/// let opts = ConvertOptions::new()
22///     .letterbox([0, 0, 0, 255])
23///     .placement(LetterboxPlacement::TopLeft)
24///     .pad_output([0.0; 4])
25///     .normalization(Normalization::imagenet())
26///     .rotation(Rotation::None);
27/// # let _ = opts;
28/// ```
29#[derive(Debug, Clone, Copy, PartialEq, Default)]
30pub struct ConvertOptions {
31    rotation: Rotation,
32    flip: Flip,
33    crop: Crop,
34    placement: LetterboxPlacement,
35    pad_output: Option<[f32; 4]>,
36    normalization: Normalization,
37    interpolation: Option<Interpolation>,
38}
39
40impl ConvertOptions {
41    /// The default options.
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Rotate the output, in 90° steps.
47    pub fn rotation(mut self, rotation: Rotation) -> Self {
48        self.rotation = rotation;
49        self
50    }
51
52    /// Flip the output.
53    pub fn flip(mut self, flip: Flip) -> Self {
54        self.flip = flip;
55        self
56    }
57
58    /// Set the source region and fit together.
59    pub fn crop(mut self, crop: Crop) -> Self {
60        self.crop = crop;
61        self
62    }
63
64    /// Sample only `source` of the input; `None` samples all of it.
65    pub fn source(mut self, source: Option<Region>) -> Self {
66        self.crop.source = source;
67        self
68    }
69
70    /// Letterbox: keep the source aspect ratio and pad the rest with the
71    /// RGBA pixel `pad`, which is mapped like the image.
72    pub fn letterbox(mut self, pad: [u8; 4]) -> Self {
73        self.crop.fit = crate::Fit::Letterbox { pad };
74        self
75    }
76
77    /// Where a letterboxed image sits. Ignored without a letterbox fit.
78    pub fn placement(mut self, placement: LetterboxPlacement) -> Self {
79        self.placement = placement;
80        self
81    }
82
83    /// Pad with these values as written to the destination (after
84    /// normalization; quantized for integer destinations), instead of mapping
85    /// the pixel pad. Ignored without a letterbox fit.
86    pub fn pad_output(mut self, pad: [f32; 4]) -> Self {
87        self.pad_output = Some(pad);
88        self
89    }
90
91    /// How pixel values map to the destination.
92    pub fn normalization(mut self, normalization: impl Into<Normalization>) -> Self {
93        self.normalization = normalization.into();
94        self
95    }
96
97    /// Resample with `interpolation` when this convert resizes, instead of
98    /// the processor's setting (see
99    /// [`ImageProcessor::set_interpolation`](crate::ImageProcessor::set_interpolation)).
100    /// [`Interpolation::Bicubic`] and [`Interpolation::Area`] run as
101    /// [`Interpolation::supported`], with a warning logged once per mode.
102    /// `EDGEFIRST_INTERPOLATION`, when set, takes precedence.
103    pub fn interpolation(mut self, interpolation: Interpolation) -> Self {
104        self.interpolation = Some(interpolation);
105        self
106    }
107
108    /// The output rotation.
109    pub fn get_rotation(&self) -> Rotation {
110        self.rotation
111    }
112
113    /// The output flip.
114    pub fn get_flip(&self) -> Flip {
115        self.flip
116    }
117
118    /// The source region and fit.
119    pub fn get_crop(&self) -> Crop {
120        self.crop
121    }
122
123    /// The letterbox placement.
124    pub fn get_placement(&self) -> LetterboxPlacement {
125        self.placement
126    }
127
128    /// The output-domain pad, when set.
129    pub fn get_pad_output(&self) -> Option<[f32; 4]> {
130        self.pad_output
131    }
132
133    /// The pixel value mapping.
134    pub fn get_normalization(&self) -> Normalization {
135        self.normalization
136    }
137
138    /// The per-convert interpolation, or `None` for the processor's.
139    pub fn get_interpolation(&self) -> Option<Interpolation> {
140        self.interpolation
141    }
142
143    /// These options with the per-convert interpolation set to `mode`, or
144    /// cleared by `None`.
145    pub(crate) fn with_interpolation(mut self, mode: Option<Interpolation>) -> Self {
146        self.interpolation = mode;
147        self
148    }
149}
150
151impl ConvertOptions {
152    /// Options that apply a model's preprocessing: its normalization, fit,
153    /// letterbox placement, pad and interpolation.
154    ///
155    /// # Errors
156    ///
157    /// [`Error::InvalidOptions`] for a pixel-space pad outside 0–255 or a
158    /// non-finite pad.
159    ///
160    /// ```
161    /// use edgefirst_image::{ConvertOptions, LetterboxPlacement, PadSpace, PreprocessSpec};
162    ///
163    /// let mut spec = PreprocessSpec::new();
164    /// spec.placement = LetterboxPlacement::TopLeft;
165    /// spec.pad = [0.0; 3];
166    /// spec.pad_space = PadSpace::Normalized;
167    /// let opts = ConvertOptions::from_spec(&spec)?;
168    /// assert_eq!(opts.get_placement(), LetterboxPlacement::TopLeft);
169    /// assert_eq!(opts.get_pad_output(), Some([0.0, 0.0, 0.0, 1.0]));
170    /// assert_eq!(opts.get_interpolation(), Some(spec.interpolation));
171    /// # Ok::<(), edgefirst_image::Error>(())
172    /// ```
173    pub fn from_spec(spec: &PreprocessSpec) -> Result<Self> {
174        Self::new().with_spec(spec)
175    }
176
177    /// These options with a model's preprocessing applied; rotation, flip
178    /// and the source crop are kept. See [`from_spec`](Self::from_spec).
179    ///
180    /// # Errors
181    ///
182    /// As [`from_spec`](Self::from_spec).
183    pub fn with_spec(mut self, spec: &PreprocessSpec) -> Result<Self> {
184        if spec.pad.iter().any(|v| !v.is_finite()) {
185            return Err(Error::InvalidOptions(format!(
186                "letterbox pad {:?} must be finite",
187                spec.pad
188            )));
189        }
190        self.normalization = spec.normalization;
191        self.interpolation = Some(spec.interpolation);
192        self.pad_output = None;
193        // Comparisons, not a match: an unknown fit stretches and an unknown pad space is pixel.
194        self.crop.fit = if spec.fit != FitMode::Letterbox {
195            crate::Fit::Stretch
196        } else if spec.pad_space == PadSpace::Normalized {
197            let [r, g, b] = spec.pad;
198            self.pad_output = Some([r, g, b, 1.0]);
199            crate::Fit::Letterbox {
200                pad: [0, 0, 0, 255],
201            }
202        } else {
203            if spec.pad.iter().any(|v| !(0.0..=255.0).contains(v)) {
204                return Err(Error::InvalidOptions(format!(
205                    "pixel letterbox pad {:?} must be within 0-255",
206                    spec.pad
207                )));
208            }
209            let [r, g, b] = spec.pad.map(|v| v.round() as u8);
210            crate::Fit::Letterbox {
211                pad: [r, g, b, 255],
212            }
213        };
214        if spec.fit == FitMode::Letterbox {
215            self.placement = spec.placement;
216        }
217        Ok(self)
218    }
219
220    /// Where `convert` puts the image content when converting an
221    /// `src_w`×`src_h` source into a `dst_w`×`dst_h` destination with these
222    /// options: the letterbox rectangle, or the whole destination for a
223    /// stretch. It is computed by the same code the convert paints with, so
224    /// mapping results back through it is exact.
225    ///
226    /// # Errors
227    ///
228    /// [`Error::CropInvalid`] when the source crop falls outside the source.
229    ///
230    /// ```
231    /// use edgefirst_image::{ConvertOptions, Region};
232    ///
233    /// let opts = ConvertOptions::new().letterbox([114, 114, 114, 255]);
234    /// let r = opts.content_region(1280, 720, 640, 640)?;
235    /// assert_eq!(r.pixels, Region::new(0, 140, 640, 360));
236    /// assert_eq!(r.normalized, [0.0, 0.21875, 1.0, 0.78125]);
237    /// # Ok::<(), edgefirst_image::Error>(())
238    /// ```
239    pub fn content_region(
240        &self,
241        src_w: usize,
242        src_h: usize,
243        dst_w: usize,
244        dst_h: usize,
245    ) -> Result<ContentRegion> {
246        let resolved = self
247            .crop
248            .resolve(src_w, src_h, dst_w, dst_h, self.placement)?;
249        Ok(match resolved.dst_rect {
250            Some(r) => {
251                ContentRegion::new(Region::new(r.left, r.top, r.width, r.height), dst_w, dst_h)
252            }
253            None => ContentRegion::full(dst_w, dst_h),
254        })
255    }
256}
257
258impl ContentRegionSource for ConvertOptions {
259    fn content_region(
260        &self,
261        src_w: usize,
262        src_h: usize,
263        dst_w: usize,
264        dst_h: usize,
265    ) -> edgefirst_tensor::Result<ContentRegion> {
266        ConvertOptions::content_region(self, src_w, src_h, dst_w, dst_h)
267            .map_err(|e| edgefirst_tensor::Error::InvalidArgument(e.to_string()))
268    }
269}
270
271/// The output mapping a convert must apply beyond the plain one, or `None`
272/// when the plain mapping is exact. `letterboxed` says whether the resolved
273/// crop pads the destination.
274pub(crate) fn output_map_for(
275    options: &ConvertOptions,
276    dst: &edgefirst_tensor::TensorDyn,
277    letterboxed: bool,
278) -> Result<Option<(OutputMap, Option<[f32; 4]>)>> {
279    let fmt = dst.format().ok_or(Error::NotAnImage)?;
280    let mappable = matches!(
281        fmt.layout(),
282        edgefirst_tensor::PixelLayout::Packed | edgefirst_tensor::PixelLayout::Planar
283    ) && !fmt.is_yuv();
284    let pad_output = options.pad_output.filter(|_| letterboxed);
285    if !mappable {
286        if options.normalization != Normalization::default() || pad_output.is_some() {
287            return Err(Error::NotSupported(format!(
288                "normalization and output-domain padding do not apply to {fmt:?}"
289            )));
290        }
291        return Ok(None);
292    }
293    let map = OutputMap::resolve(&options.normalization, dst.dtype(), fmt, dst.quantization())?;
294    if map.is_default && pad_output.is_none() {
295        return Ok(None);
296    }
297    Ok(Some((map, pad_output)))
298}
299
300/// How a destination element is produced from an 8-bit source sample `x`,
301/// per destination channel `c`:
302///
303/// - float destinations: `x * scale[c] + offset[c]`;
304/// - integer destinations: `clamp(floor(x * scale[c] + offset[c] + 0.5) +
305///   zero_point[c], lo[c], hi[c])`, stored as its low byte.
306///
307/// Colour channels carry the normalization (and quantization). Alpha maps to
308/// `x / 255` for float destinations and stays the raw byte for integer ones,
309/// as a plain convert always has.
310#[derive(Debug, Clone, Copy, PartialEq)]
311pub(crate) struct OutputMap {
312    /// Per destination channel, in the destination's channel order.
313    pub(crate) scale: [f32; 4],
314    pub(crate) offset: [f32; 4],
315    pub(crate) zero_point: [f32; 4],
316    /// `Some([(lo, hi); 4])` for integer destinations.
317    pub(crate) bounds: Option<[(f32, f32); 4]>,
318    /// Integer destinations: the quantization scale per channel, which an
319    /// output-domain pad is divided by.
320    pub(crate) quant_scale: [f32; 4],
321    /// The mapping a plain convert has always applied for this dtype.
322    pub(crate) is_default: bool,
323}
324
325impl OutputMap {
326    /// Resolve the mapping for a destination of `dtype` and `format`, with
327    /// optional quantization. Integer destinations without quantization use
328    /// scale 1/255 and zero point 0 (u8) or -128 (i8), which reproduces a
329    /// plain u8 copy and the i8 `x ^ 0x80` bias.
330    pub(crate) fn resolve(
331        normalization: &Normalization,
332        dtype: DType,
333        format: PixelFormat,
334        quantization: Option<&Quantization>,
335    ) -> Result<Self> {
336        let plain = *normalization == Normalization::default();
337        let order = channel_order(format);
338        let (lo, hi, implicit_zp) = match dtype {
339            DType::F32 | DType::F16 => {
340                let (scale, offset) = channel_affine(normalization, order, [1.0; 4]);
341                return Ok(Self {
342                    scale,
343                    offset,
344                    zero_point: [0.0; 4],
345                    bounds: None,
346                    quant_scale: [1.0; 4],
347                    is_default: plain,
348                });
349            }
350            DType::U8 => (0.0, 255.0, 0),
351            DType::I8 => (-128.0, 127.0, -128),
352            other => {
353                return Err(Error::NotSupported(format!(
354                    "convert does not write {other:?} destinations"
355                )))
356            }
357        };
358        let (qscale, qzp) = match quantization {
359            None if plain => ([1.0 / 255.0; 4], [implicit_zp as f32; 4]),
360            None => {
361                return Err(Error::InvalidOptions(format!(
362                    "a {dtype:?} destination needs quantization to apply a \
363                     non-default normalization"
364                )))
365            }
366            Some(q) => quant_per_channel(q, format, format.channels())?,
367        };
368        let (mut scale, mut offset) = channel_affine(normalization, order, qscale);
369        let mut zero_point = qzp;
370        let mut quant_scale = qscale;
371        let mut bounds = [(lo, hi); 4];
372        for (c, &src) in order.iter().enumerate() {
373            if src == 3 {
374                (scale[c], offset[c], zero_point[c], quant_scale[c]) = (1.0, 0.0, 0.0, 1.0);
375                bounds[c] = (0.0, 255.0);
376            }
377        }
378        let is_default = plain
379            && qscale.iter().all(|s| *s == 1.0 / 255.0)
380            && qzp.iter().all(|z| *z == implicit_zp as f32);
381        Ok(Self {
382            scale,
383            offset,
384            zero_point,
385            bounds: Some(bounds),
386            quant_scale,
387            is_default,
388        })
389    }
390
391    /// Map an 8-bit sample on channel `c`.
392    pub(crate) fn apply(&self, c: usize, x: u8) -> f32 {
393        let y = x as f32 * self.scale[c] + self.offset[c];
394        match self.bounds {
395            Some(b) => ((y + 0.5).floor() + self.zero_point[c]).clamp(b[c].0, b[c].1),
396            None => y,
397        }
398    }
399
400    /// The destination value for an output-domain value `v` on channel `c`.
401    pub(crate) fn apply_output(&self, c: usize, v: f32) -> f32 {
402        match self.bounds {
403            Some(b) => {
404                ((v / self.quant_scale[c] + 0.5).floor() + self.zero_point[c]).clamp(b[c].0, b[c].1)
405            }
406            None => v,
407        }
408    }
409
410    /// The 8-bit sample that maps to exactly `target` on integer channel `c`,
411    /// preferring the one whose unrounded value sits farthest from a rounding
412    /// tie; `None` when no sample reaches it.
413    #[cfg_attr(not(feature = "opengl"), allow(dead_code))]
414    pub(crate) fn sample_for(&self, c: usize, target: f32) -> Option<u8> {
415        self.bounds?;
416        (0..=255u8)
417            .filter(|&x| self.apply(c, x) == target)
418            .min_by(|&a, &b| {
419                let off = |x: u8| {
420                    let y = x as f32 * self.scale[c] + self.offset[c];
421                    ((y - y.floor()) - 0.5).abs()
422                };
423                off(b).total_cmp(&off(a))
424            })
425    }
426
427    /// The destination values of the letterbox pad: `pad_output` as given,
428    /// else the pixel `pad` mapped like the image.
429    pub(crate) fn pad_values(&self, pad: [u8; 4], pad_output: Option<[f32; 4]>) -> [f32; 4] {
430        std::array::from_fn(|c| match pad_output {
431            Some(p) => self.apply_output(c, p[c]),
432            None => self.apply(c, pad[c]),
433        })
434    }
435}
436
437/// The destination's colour channel order as indices into RGB, with `3` for
438/// alpha.
439fn channel_order(format: PixelFormat) -> [usize; 4] {
440    match format {
441        PixelFormat::Bgra => [2, 1, 0, 3],
442        PixelFormat::Grey => [0, 3, 3, 3],
443        _ => [0, 1, 2, 3],
444    }
445}
446
447/// Fold normalization and the quantization scale into one per-channel affine
448/// on 8-bit samples, in the destination's channel order. Alpha maps as
449/// `x / 255` before quantization.
450fn channel_affine(n: &Normalization, order: [usize; 4], qscale: [f32; 4]) -> ([f32; 4], [f32; 4]) {
451    let mut scale = [0.0; 4];
452    let mut offset = [0.0; 4];
453    for (c, &src) in order.iter().enumerate() {
454        let (mean, std) = if src == 3 {
455            (0.0, 255.0)
456        } else {
457            (n.mean()[src] as f64, n.std()[src] as f64)
458        };
459        let qs = qscale[c] as f64;
460        scale[c] = (1.0 / (std * qs)) as f32;
461        offset[c] = (-mean / (std * qs)) as f32;
462    }
463    (scale, offset)
464}
465
466/// Per destination channel scale and zero point.
467fn quant_per_channel(
468    q: &Quantization,
469    format: PixelFormat,
470    channels: usize,
471) -> Result<([f32; 4], [f32; 4])> {
472    let per_tensor = |s: f32, z: i32| ([s; 4], [z as f32; 4]);
473    let (s, z) = match q.mode() {
474        QuantMode::PerTensorSymmetric { scale } => per_tensor(scale, 0),
475        QuantMode::PerTensor { scale, zero_point } => per_tensor(scale, zero_point),
476        QuantMode::PerChannelSymmetric { scales, .. } if scales.len() == channels => {
477            let mut s = [scales[0]; 4];
478            s[..channels].copy_from_slice(scales);
479            (s, [0.0; 4])
480        }
481        QuantMode::PerChannel {
482            scales,
483            zero_points,
484            ..
485        } if scales.len() == channels => {
486            let mut s = [scales[0]; 4];
487            let mut z = [zero_points[0] as f32; 4];
488            s[..channels].copy_from_slice(scales);
489            for (d, zp) in z.iter_mut().zip(zero_points) {
490                *d = *zp as f32;
491            }
492            (s, z)
493        }
494        _ => {
495            return Err(Error::InvalidOptions(format!(
496                "{format:?} destination quantization must be per-tensor or \
497                 have one scale per channel ({channels})"
498            )))
499        }
500    };
501    if s.iter().any(|v| !v.is_finite() || *v <= 0.0) {
502        return Err(Error::InvalidOptions(format!(
503            "destination quantization scale {s:?} must be positive and finite"
504        )));
505    }
506    Ok((s, z))
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn default_float_map_is_divide_by_255() {
515        let m = OutputMap::resolve(
516            &Normalization::default(),
517            DType::F32,
518            PixelFormat::Rgb,
519            None,
520        )
521        .unwrap();
522        assert!(m.is_default);
523        for x in [0u8, 1, 128, 255] {
524            assert_eq!(m.apply(0, x), x as f32 / 255.0);
525        }
526    }
527
528    #[test]
529    fn implicit_int_quantization_reproduces_plain_converts() {
530        let u = OutputMap::resolve(&Normalization::default(), DType::U8, PixelFormat::Rgb, None)
531            .unwrap();
532        let i = OutputMap::resolve(&Normalization::default(), DType::I8, PixelFormat::Rgb, None)
533            .unwrap();
534        assert!(u.is_default && i.is_default);
535        for x in 0..=255u8 {
536            assert_eq!(u.apply(1, x), x as f32);
537            assert_eq!(i.apply(1, x) as i8 as u8, x ^ 0x80);
538        }
539    }
540
541    #[test]
542    fn imagenet_matches_reference_in_bgra_order() {
543        let n = Normalization::imagenet();
544        let m = OutputMap::resolve(&n, DType::F32, PixelFormat::Bgra, None).unwrap();
545        assert!(!m.is_default);
546        let want_b = (200.0 - 103.53) / 57.375;
547        assert!((m.apply(0, 200) - want_b).abs() < 1e-5);
548        assert!((m.apply(3, 255) - 1.0).abs() < 1e-6, "alpha stays unsigned");
549    }
550
551    #[test]
552    fn int_normalization_needs_quantization() {
553        let e = OutputMap::resolve(&Normalization::signed(), DType::I8, PixelFormat::Rgb, None);
554        assert!(matches!(e, Err(Error::InvalidOptions(_))));
555        let q = Quantization::per_tensor(1.0 / 128.0, 0);
556        let m = OutputMap::resolve(
557            &Normalization::signed(),
558            DType::I8,
559            PixelFormat::Rgb,
560            Some(&q),
561        )
562        .unwrap();
563        assert_eq!(m.apply(0, 0), -128.0);
564        assert_eq!(m.apply(0, 255), 127.0, "128 clamps to the i8 range");
565        assert_eq!(m.apply(0, 191), 64.0);
566    }
567
568    #[test]
569    fn an_output_pad_has_an_equivalent_sample_when_reachable() {
570        let q = Quantization::per_tensor(0.0187, -14);
571        let m = OutputMap::resolve(
572            &Normalization::imagenet(),
573            DType::I8,
574            PixelFormat::Rgb,
575            Some(&q),
576        )
577        .unwrap();
578        let pad = m.pad_values([0; 4], Some([0.0; 4]));
579        for (c, &want) in pad.iter().enumerate().take(3) {
580            let x = m.sample_for(c, want).expect("0.0 is reachable");
581            assert_eq!(m.apply(c, x), want);
582        }
583        // A coarse quantization skips values: 10.0 / 0.5 is not reachable at
584        // scale 1/255 per step 0.5.
585        let q = Quantization::per_tensor(1.0 / 512.0, 0);
586        let m = OutputMap::resolve(
587            &Normalization::unsigned(),
588            DType::U8,
589            PixelFormat::Rgb,
590            Some(&q),
591        )
592        .unwrap();
593        assert_eq!(m.sample_for(0, 1.0), None);
594    }
595}