Skip to main content

otf_pixels_ops/
adjust.rs

1//! Per-pixel adjustments, after sharp: [`Negate`], [`Gamma`], [`Linear`],
2//! [`Threshold`], [`Tint`], [`Greyscale`], [`Recomb`] and [`EnsureAlpha`].
3//!
4//! Each reads one pixel as RGBA in 0..=1 — whatever its depth and layout —
5//! and writes one back, so a single definition serves 8-bit, 16-bit and float
6//! images alike, and the output is independent of tiling by construction.
7//!
8//! # Alpha
9//!
10//! Alpha is unassociated (SPEC §Formats) and passes through untouched unless
11//! an op says otherwise: [`Negate`] inverts it when asked, [`Recomb`] mixes it
12//! with a 4x4 matrix, and [`EnsureAlpha`] adds it.
13
14use std::sync::Arc;
15
16use otf_pixels_core::{
17    ChannelLayout, ImageDescriptor, Op, PixelFormat, PixelsError, Region, Result, Tile, TileMut,
18};
19
20use crate::convert::{from_rgba, to_rgba};
21
22/// BT.601 luma, the weighting [`crate::ConvertFormat`] and sharp's
23/// `greyscale` use.
24fn luma([r, g, b, _]: [f32; 4]) -> f32 {
25    0.299 * r + 0.587 * g + 0.114 * b
26}
27
28/// The colour format a greyscale one widens to, for ops whose result has
29/// colour even when the input has none.
30const fn with_colour(format: PixelFormat) -> PixelFormat {
31    match format {
32        PixelFormat::Gray8 => PixelFormat::Rgb8,
33        PixelFormat::GrayA8 => PixelFormat::Rgba8,
34        PixelFormat::Gray16 => PixelFormat::Rgb16,
35        other => other,
36    }
37}
38
39/// What every op in this module is: a function from one RGBA pixel to
40/// another, and the format it writes.
41trait PixelMap: Send + Sync + std::fmt::Debug {
42    fn name(&self) -> &'static str;
43
44    /// The output format for `input`; the same by default.
45    fn output(&self, input: PixelFormat) -> Result<PixelFormat> {
46        Ok(input)
47    }
48
49    /// Map one pixel, RGBA in 0..=1. For a greyscale input, red, green and
50    /// blue are equal and the result is narrowed back by luma.
51    fn map(&self, rgba: [f32; 4]) -> [f32; 4];
52}
53
54/// Apply `map` to every pixel of `input`, writing `output`.
55fn compute<M: PixelMap + ?Sized>(
56    map: &M,
57    inputs: &[Tile<'_>],
58    output: &mut TileMut<'_>,
59) -> Result<()> {
60    let input = inputs
61        .first()
62        .ok_or_else(|| PixelsError::graph(format!("`{}` takes one input tile", map.name())))?;
63    let (from, to) = (input.pixel(), output.pixel());
64    let (in_bytes, out_bytes) = (from.bytes_per_pixel(), to.bytes_per_pixel());
65    let region = output.region();
66    for y in region.y..region.y + region.height {
67        let (Some(source), Some(target)) = (input.row(y), output.row_mut(y)) else {
68            continue;
69        };
70        for (pixel, slot) in source
71            .chunks_exact(in_bytes)
72            .zip(target.chunks_exact_mut(out_bytes))
73        {
74            from_rgba(map.map(to_rgba(pixel, from)), slot, to);
75        }
76    }
77    Ok(())
78}
79
80/// Implement [`Op`] for a [`PixelMap`]: one input, the same region, and a
81/// pointwise op is the same at any resolution.
82macro_rules! pointwise_op {
83    ($type:ty) => {
84        impl Op for $type {
85            fn name(&self) -> &'static str {
86                PixelMap::name(self)
87            }
88
89            fn rescaled(&self) -> Option<Arc<dyn Op>> {
90                Some(Arc::new(self.clone()))
91            }
92
93            fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
94                let [input] = inputs else {
95                    return Err(PixelsError::graph(format!(
96                        "`{}` takes exactly one input",
97                        PixelMap::name(self)
98                    )));
99                };
100                let mut out = *input;
101                out.pixel = self.output(input.pixel)?;
102                Ok(out)
103            }
104
105            fn input_regions(
106                &self,
107                output: Region,
108                _inputs: &[ImageDescriptor],
109            ) -> Result<Vec<Region>> {
110                Ok(vec![output])
111            }
112
113            fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
114                compute(self, inputs, output)
115            }
116        }
117    };
118}
119
120/// Check a parameter is a finite number.
121fn finite(name: &'static str, value: f32) -> Result<f32> {
122    if value.is_finite() {
123        Ok(value)
124    } else {
125        Err(PixelsError::invalid_argument(
126            name,
127            format!("must be finite, got {value}"),
128        ))
129    }
130}
131
132/// Invert colours: each channel becomes `1 - value` (sharp's `negate`).
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct Negate {
135    alpha: bool,
136}
137
138impl Negate {
139    /// Invert colours, and alpha too if `alpha` is set. sharp inverts alpha
140    /// by default.
141    #[must_use]
142    pub const fn new(alpha: bool) -> Self {
143        Self { alpha }
144    }
145}
146
147impl PixelMap for Negate {
148    fn name(&self) -> &'static str {
149        "negate"
150    }
151
152    fn map(&self, [r, g, b, a]: [f32; 4]) -> [f32; 4] {
153        [
154            1.0 - r,
155            1.0 - g,
156            1.0 - b,
157            if self.alpha { 1.0 - a } else { a },
158        ]
159    }
160}
161pointwise_op!(Negate);
162
163/// Gamma: each colour channel becomes `value^(1 / exponent)`, as libvips'
164/// `gamma` defines it. An exponent above 1 brightens, below 1 darkens.
165///
166/// sharp's `gamma(g, gOut)` is two of these around its resize: `1 / g`
167/// before (darken) and `gOut` after (brighten), which resamples in a more
168/// nearly linear space.
169#[derive(Debug, Clone, Copy, PartialEq)]
170pub struct Gamma {
171    exponent: f32,
172}
173
174impl Gamma {
175    /// Gamma with `exponent`.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`PixelsError::InvalidArgument`] unless `exponent` is finite
180    /// and positive.
181    pub fn new(exponent: f32) -> Result<Self> {
182        if finite("exponent", exponent)? <= 0.0 {
183            return Err(PixelsError::invalid_argument(
184                "exponent",
185                format!("must be positive, got {exponent}"),
186            ));
187        }
188        Ok(Self { exponent })
189    }
190}
191
192impl PixelMap for Gamma {
193    fn name(&self) -> &'static str {
194        "gamma"
195    }
196
197    fn map(&self, [r, g, b, a]: [f32; 4]) -> [f32; 4] {
198        let power = 1.0 / self.exponent;
199        let apply = |v: f32| v.max(0.0).powf(power);
200        [apply(r), apply(g), apply(b), a]
201    }
202}
203pointwise_op!(Gamma);
204
205/// A per-channel linear map, `value * a + b` (sharp's `linear`). `b` is in
206/// 8-bit units, as in sharp: an offset of 255 is the full range, at any
207/// depth. Alpha is untouched.
208#[derive(Debug, Clone, Copy, PartialEq)]
209pub struct Linear {
210    multiply: [f32; 3],
211    add: [f32; 3],
212}
213
214impl Linear {
215    /// The same `a` and `b` for every colour channel.
216    ///
217    /// # Errors
218    ///
219    /// Returns [`PixelsError::InvalidArgument`] unless both are finite.
220    pub fn new(a: f32, b: f32) -> Result<Self> {
221        Self::per_channel([a; 3], [b; 3])
222    }
223
224    /// A separate `a` and `b` for red, green and blue. Greyscale images use
225    /// the first of each.
226    ///
227    /// # Errors
228    ///
229    /// As [`Linear::new`].
230    pub fn per_channel(a: [f32; 3], b: [f32; 3]) -> Result<Self> {
231        for value in a {
232            finite("a", value)?;
233        }
234        for value in b {
235            finite("b", value)?;
236        }
237        Ok(Self {
238            multiply: a,
239            add: b.map(|v| v / 255.0),
240        })
241    }
242}
243
244impl PixelMap for Linear {
245    fn name(&self) -> &'static str {
246        "linear"
247    }
248
249    fn map(&self, [r, g, b, a]: [f32; 4]) -> [f32; 4] {
250        let [mr, mg, mb] = self.multiply;
251        let [ar, ag, ab] = self.add;
252        [r * mr + ar, g * mg + ag, b * mb + ab, a]
253    }
254}
255pointwise_op!(Linear);
256
257/// Two-level threshold (sharp's `threshold`): a value at or above the
258/// threshold becomes full, anything below becomes zero.
259///
260/// With `greyscale`, the default in sharp, the pixel's luma is thresholded
261/// and written to every colour channel; without it each channel is
262/// thresholded on its own. The channel count stays the input's, so an RGB
263/// image stays RGB (sharp returns one channel); the values are the same.
264#[derive(Debug, Clone, Copy, PartialEq)]
265pub struct Threshold {
266    level: f32,
267    greyscale: bool,
268}
269
270impl Threshold {
271    /// Threshold at `level`, in 8-bit units: 128 is sharp's default.
272    #[must_use]
273    pub fn new(level: u8, greyscale: bool) -> Self {
274        Self {
275            level: f32::from(level) / 255.0,
276            greyscale,
277        }
278    }
279}
280
281impl PixelMap for Threshold {
282    fn name(&self) -> &'static str {
283        "threshold"
284    }
285
286    fn map(&self, rgba: [f32; 4]) -> [f32; 4] {
287        // Compared in 8-bit steps, so a 16-bit 0x8000 and an 8-bit 128 agree,
288        // and float rounding cannot move a value across the line.
289        let step = |v: f32| {
290            if (v * 255.0).round() >= (self.level * 255.0).round() {
291                1.0
292            } else {
293                0.0
294            }
295        };
296        let [r, g, b, a] = rgba;
297        if self.greyscale {
298            let value = step(luma(rgba));
299            [value, value, value, a]
300        } else {
301            [step(r), step(g), step(b), a]
302        }
303    }
304}
305pointwise_op!(Threshold);
306
307/// Tint with a colour (sharp's `tint`): each pixel keeps its luma and takes
308/// its chroma from the tint.
309///
310/// sharp does this in Lab; this does it in YCbCr (BT.601), so tinted colours
311/// are close to sharp's but not identical. A saturated tint can push a pixel
312/// outside the RGB gamut, where it is clipped, as in sharp. A greyscale input
313/// becomes colour.
314#[derive(Debug, Clone, Copy, PartialEq)]
315pub struct Tint {
316    /// The tint's blue-difference and red-difference chroma.
317    chroma: [f32; 2],
318}
319
320impl Tint {
321    /// Tint towards an 8-bit RGB colour.
322    #[must_use]
323    pub fn new(red: u8, green: u8, blue: u8) -> Self {
324        let rgba = [red, green, blue].map(|v| f32::from(v) / 255.0);
325        let [r, g, b] = rgba;
326        let y = luma([r, g, b, 1.0]);
327        Self {
328            chroma: [(b - y) * 0.564, (r - y) * 0.713],
329        }
330    }
331}
332
333impl PixelMap for Tint {
334    fn name(&self) -> &'static str {
335        "tint"
336    }
337
338    fn output(&self, input: PixelFormat) -> Result<PixelFormat> {
339        Ok(with_colour(input))
340    }
341
342    fn map(&self, rgba: [f32; 4]) -> [f32; 4] {
343        let y = luma(rgba);
344        let [cb, cr] = self.chroma;
345        [
346            y + 1.403 * cr,
347            y - 0.344 * cb - 0.714 * cr,
348            y + 1.773 * cb,
349            rgba[3],
350        ]
351    }
352}
353pointwise_op!(Tint);
354
355/// Greyscale (sharp's `greyscale`): every colour channel becomes the pixel's
356/// BT.601 luma. The channel count is kept, as in sharp, whose greyscale
357/// output is three identical channels; alpha is untouched.
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
359pub struct Greyscale;
360
361impl PixelMap for Greyscale {
362    fn name(&self) -> &'static str {
363        "greyscale"
364    }
365
366    fn map(&self, rgba: [f32; 4]) -> [f32; 4] {
367        let y = luma(rgba);
368        [y, y, y, rgba[3]]
369    }
370}
371pointwise_op!(Greyscale);
372
373/// Recombine channels through a matrix (sharp's `recomb`): sepia, channel
374/// swaps and the like.
375///
376/// A 3x3 matrix mixes red, green and blue and leaves alpha alone; a 4x4 one
377/// mixes alpha in too, and adds an opaque alpha to an image without one so
378/// there is somewhere to put it. A greyscale input becomes colour.
379#[derive(Debug, Clone, Copy, PartialEq)]
380pub struct Recomb {
381    matrix: [[f32; 4]; 4],
382    alpha: bool,
383}
384
385impl Recomb {
386    /// Recombine red, green and blue: `out[i] = sum(matrix[i][j] * in[j])`.
387    ///
388    /// # Errors
389    ///
390    /// Returns [`PixelsError::InvalidArgument`] if any entry is not finite.
391    pub fn rgb(matrix: [[f32; 3]; 3]) -> Result<Self> {
392        let mut full = [[0.0; 4]; 4];
393        for (row, from) in full.iter_mut().zip(matrix) {
394            for (slot, value) in row.iter_mut().zip(from) {
395                *slot = finite("matrix", value)?;
396            }
397        }
398        full[3][3] = 1.0;
399        Ok(Self {
400            matrix: full,
401            alpha: false,
402        })
403    }
404
405    /// Recombine red, green, blue and alpha.
406    ///
407    /// # Errors
408    ///
409    /// As [`Recomb::rgb`].
410    pub fn rgba(matrix: [[f32; 4]; 4]) -> Result<Self> {
411        for value in matrix.iter().flatten() {
412            finite("matrix", *value)?;
413        }
414        Ok(Self {
415            matrix,
416            alpha: true,
417        })
418    }
419}
420
421impl PixelMap for Recomb {
422    fn name(&self) -> &'static str {
423        "recomb"
424    }
425
426    fn output(&self, input: PixelFormat) -> Result<PixelFormat> {
427        let colour = with_colour(input);
428        Ok(if self.alpha {
429            with_alpha(colour)
430        } else {
431            colour
432        })
433    }
434
435    fn map(&self, rgba: [f32; 4]) -> [f32; 4] {
436        self.matrix.map(|row| {
437            row.iter()
438                .zip(rgba)
439                .map(|(weight, value)| weight * value)
440                .sum()
441        })
442    }
443}
444pointwise_op!(Recomb);
445
446/// The format with an alpha channel added, if it has none.
447const fn with_alpha(format: PixelFormat) -> PixelFormat {
448    match format {
449        PixelFormat::Gray8 => PixelFormat::GrayA8,
450        PixelFormat::Rgb8 => PixelFormat::Rgba8,
451        PixelFormat::Rgb16 => PixelFormat::Rgba16,
452        PixelFormat::RgbF32 => PixelFormat::RgbaF32,
453        // There is no 16-bit grey-with-alpha format; 16-bit RGBA holds it.
454        PixelFormat::Gray16 => PixelFormat::Rgba16,
455        other => other,
456    }
457}
458
459/// Add an alpha channel at a fixed level if there is none (sharp's
460/// `ensureAlpha`); an image that has one is unchanged.
461#[derive(Debug, Clone, Copy, PartialEq)]
462pub struct EnsureAlpha {
463    level: f32,
464}
465
466impl EnsureAlpha {
467    /// Add alpha at `level`, 0 transparent to 1 opaque; sharp's default is 1.
468    ///
469    /// # Errors
470    ///
471    /// Returns [`PixelsError::InvalidArgument`] unless `level` is in 0..=1.
472    pub fn new(level: f32) -> Result<Self> {
473        if !(0.0..=1.0).contains(&finite("level", level)?) {
474            return Err(PixelsError::invalid_argument(
475                "level",
476                format!("must be between 0 and 1, got {level}"),
477            ));
478        }
479        Ok(Self { level })
480    }
481}
482
483impl PixelMap for EnsureAlpha {
484    fn name(&self) -> &'static str {
485        "ensure_alpha"
486    }
487
488    fn output(&self, input: PixelFormat) -> Result<PixelFormat> {
489        Ok(with_alpha(input))
490    }
491
492    fn map(&self, rgba: [f32; 4]) -> [f32; 4] {
493        // Only reached with a new alpha channel to fill when the input had
494        // none, in which case `to_rgba` reported it as opaque.
495        rgba
496    }
497}
498
499impl Op for EnsureAlpha {
500    fn name(&self) -> &'static str {
501        PixelMap::name(self)
502    }
503
504    fn rescaled(&self) -> Option<Arc<dyn Op>> {
505        Some(Arc::new(*self))
506    }
507
508    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
509        let [input] = inputs else {
510            return Err(PixelsError::graph("`ensure_alpha` takes exactly one input"));
511        };
512        let mut out = *input;
513        out.pixel = self.output(input.pixel)?;
514        Ok(out)
515    }
516
517    fn input_regions(&self, output: Region, _inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
518        Ok(vec![output])
519    }
520
521    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
522        let had_alpha = inputs.first().is_some_and(|tile| {
523            matches!(
524                tile.pixel().layout(),
525                ChannelLayout::GrayAlpha | ChannelLayout::Rgba
526            )
527        });
528        if had_alpha {
529            return compute(self, inputs, output);
530        }
531        let level = self.level;
532        compute(&WithLevel(level), inputs, output)
533    }
534}
535
536/// [`EnsureAlpha`]'s map for an image gaining alpha: set it to the level.
537#[derive(Debug)]
538struct WithLevel(f32);
539
540impl PixelMap for WithLevel {
541    fn name(&self) -> &'static str {
542        "ensure_alpha"
543    }
544
545    fn map(&self, [r, g, b, _]: [f32; 4]) -> [f32; 4] {
546        [r, g, b, self.0]
547    }
548}
549
550#[cfg(test)]
551#[allow(
552    clippy::unwrap_used,
553    clippy::indexing_slicing,
554    reason = "tests operate on known-good values and assert shapes directly"
555)]
556mod tests {
557    use super::*;
558    use otf_pixels_core::TileBuf;
559
560    /// Run `op` over one whole image given as raw bytes.
561    fn run(op: &dyn Op, format: PixelFormat, width: u32, bytes: &[u8]) -> (PixelFormat, Vec<u8>) {
562        let height = (bytes.len() / format.bytes_per_pixel()) as u32 / width;
563        let input = ImageDescriptor::new(width, height, format).unwrap();
564        let out = op.output_descriptor(&[input]).unwrap();
565        let source = TileBuf::from_vec(input.region(), format, bytes.to_vec()).unwrap();
566        let mut target = TileBuf::for_image(&out).unwrap();
567        op.compute(
568            &[source.as_tile().unwrap()],
569            &mut target.as_tile_mut().unwrap(),
570        )
571        .unwrap();
572        (out.pixel, target.into_bytes())
573    }
574
575    #[test]
576    fn negate_inverts_colour_and_optionally_alpha() {
577        let (_, out) = run(
578            &Negate::new(true),
579            PixelFormat::Rgba8,
580            1,
581            &[0, 100, 255, 40],
582        );
583        assert_eq!(out, [255, 155, 0, 215]);
584        let (_, out) = run(
585            &Negate::new(false),
586            PixelFormat::Rgba8,
587            1,
588            &[0, 100, 255, 40],
589        );
590        assert_eq!(out, [255, 155, 0, 40]);
591        let (_, out) = run(
592            &Negate::new(true),
593            PixelFormat::Gray16,
594            1,
595            &1000_u16.to_ne_bytes(),
596        );
597        assert_eq!(u16::from_ne_bytes([out[0], out[1]]), 64535);
598    }
599
600    #[test]
601    fn gamma_brightens_above_one_and_leaves_the_ends_fixed() {
602        let op = Gamma::new(2.0).unwrap();
603        let (_, out) = run(&op, PixelFormat::Rgb8, 1, &[0, 64, 255]);
604        // sqrt(64 / 255) * 255 = 127.75
605        assert_eq!(out, [0, 128, 255]);
606        assert!(Gamma::new(0.0).is_err());
607        assert!(Gamma::new(f32::NAN).is_err());
608    }
609
610    #[test]
611    fn linear_multiplies_and_offsets_in_8_bit_units_and_clamps() {
612        let op = Linear::per_channel([2.0, 1.0, 0.5], [10.0, -20.0, 0.0]).unwrap();
613        let (_, out) = run(&op, PixelFormat::Rgba8, 1, &[100, 10, 200, 77]);
614        assert_eq!(out, [210, 0, 100, 77]);
615        let (_, out) = run(
616            &Linear::new(1.0, 255.0).unwrap(),
617            PixelFormat::Gray8,
618            1,
619            &[3],
620        );
621        assert_eq!(out, [255]);
622    }
623
624    #[test]
625    fn threshold_uses_luma_or_each_channel() {
626        let pixel = [200, 50, 50];
627        // Luma is 0.299*200 + 0.587*50 + 0.114*50 = 94.85, below 128.
628        let (_, out) = run(&Threshold::new(128, true), PixelFormat::Rgb8, 1, &pixel);
629        assert_eq!(out, [0, 0, 0]);
630        let (_, out) = run(&Threshold::new(128, false), PixelFormat::Rgb8, 1, &pixel);
631        assert_eq!(out, [255, 0, 0]);
632        // "At or above" is inclusive, at 8-bit and 16-bit depth alike.
633        let (_, out) = run(
634            &Threshold::new(128, false),
635            PixelFormat::Gray8,
636            2,
637            &[127, 128],
638        );
639        assert_eq!(out, [0, 255]);
640        let half = 0x8080_u16.to_ne_bytes();
641        let (_, out) = run(&Threshold::new(128, false), PixelFormat::Gray16, 1, &half);
642        assert_eq!(out, [255, 255]);
643    }
644
645    #[test]
646    fn greyscale_keeps_three_equal_channels_and_alpha() {
647        let (format, out) = run(&Greyscale, PixelFormat::Rgba8, 1, &[200, 50, 50, 9]);
648        assert_eq!(format, PixelFormat::Rgba8);
649        assert_eq!(out, [95, 95, 95, 9]);
650    }
651
652    #[test]
653    fn tint_keeps_luma_and_takes_the_tints_hue() {
654        // A tint whose chroma stays in gamut at this luma; a fully saturated
655        // one would clip, and clipping is what moves luma.
656        let (format, out) = run(&Tint::new(200, 100, 100), PixelFormat::Gray8, 1, &[128]);
657        assert_eq!(format, PixelFormat::Rgb8);
658        let [r, g, b] = [out[0], out[1], out[2]];
659        assert!(r > g && r > b, "a red tint should be red: {out:?}");
660        let y = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
661        assert!((y - 128.0).abs() <= 2.0, "luma moved to {y}");
662        // A grey tint changes nothing on a grey pixel.
663        let (_, out) = run(&Tint::new(90, 90, 90), PixelFormat::Rgb8, 1, &[70, 70, 70]);
664        assert_eq!(out, [70, 70, 70]);
665    }
666
667    #[test]
668    fn recomb_applies_the_matrix() {
669        let swap = Recomb::rgb([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]).unwrap();
670        let (_, out) = run(&swap, PixelFormat::Rgba8, 1, &[10, 20, 30, 40]);
671        assert_eq!(out, [30, 20, 10, 40]);
672        let alpha_from_red = Recomb::rgba([
673            [1.0, 0.0, 0.0, 0.0],
674            [0.0, 1.0, 0.0, 0.0],
675            [0.0, 0.0, 1.0, 0.0],
676            [1.0, 0.0, 0.0, 0.0],
677        ])
678        .unwrap();
679        let (format, out) = run(&alpha_from_red, PixelFormat::Rgb8, 1, &[10, 20, 30]);
680        assert_eq!(format, PixelFormat::Rgba8);
681        assert_eq!(out, [10, 20, 30, 10]);
682        assert!(Recomb::rgb([[f32::INFINITY; 3]; 3]).is_err());
683    }
684
685    #[test]
686    fn ensure_alpha_adds_a_level_only_when_missing() {
687        let op = EnsureAlpha::new(0.5).unwrap();
688        let (format, out) = run(&op, PixelFormat::Rgb8, 1, &[1, 2, 3]);
689        assert_eq!(format, PixelFormat::Rgba8);
690        assert_eq!(out, [1, 2, 3, 128]);
691        let (format, out) = run(&op, PixelFormat::Rgba8, 1, &[1, 2, 3, 4]);
692        assert_eq!(format, PixelFormat::Rgba8);
693        assert_eq!(out, [1, 2, 3, 4]);
694        let (format, _) = run(&op, PixelFormat::Gray16, 1, &[0, 0]);
695        assert_eq!(format, PixelFormat::Rgba16);
696        assert!(EnsureAlpha::new(1.5).is_err());
697    }
698}