Skip to main content

otf_pixels_ops/
pointwise.rs

1//! Pointwise ops: [`Modulate`], [`ExtractChannel`] and [`Flatten`].
2//!
3//! Every op here reads one input pixel and writes one output pixel. That makes
4//! them the easiest ops to get right and the most important ones to make fast:
5//! they are memory-bound, so the whole game is keeping the inner loop free of
6//! branches and letting the compiler widen it (ADR-0011).
7//!
8//! # Alpha
9//!
10//! SPEC §Formats: alpha is **unassociated** at API boundaries. `modulate`
11//! therefore leaves alpha alone rather than scaling it, and `flatten`
12//! composites against a background using straight alpha. An op that quietly
13//! premultiplied would change what a subsequent `composite` means.
14
15use otf_pixels_core::{
16    ChannelLayout, ImageDescriptor, Op, PixelFormat, PixelsError, Region, Result, SampleKind, Tile,
17    TileMut,
18};
19
20/// Brightness, saturation and hue adjustment (SPEC §Core ops).
21///
22/// Saturation and hue are defined in HSV, which is what `modulate` means
23/// everywhere else in this corner of the ecosystem. That is a deliberate
24/// compatibility choice rather than a claim that HSV is the right colour
25/// model: it is not perceptually uniform, and v2's ICC work is where a
26/// principled version belongs.
27#[derive(Debug, Clone, Copy, PartialEq)]
28#[non_exhaustive]
29pub struct Modulate {
30    /// Multiplier on value/lightness. 1.0 leaves it unchanged.
31    pub brightness: f32,
32    /// Multiplier on saturation. 1.0 leaves it unchanged, 0.0 is greyscale.
33    pub saturation: f32,
34    /// Rotation of hue in degrees.
35    pub hue: f32,
36}
37
38impl Default for Modulate {
39    fn default() -> Self {
40        Self {
41            brightness: 1.0,
42            saturation: 1.0,
43            hue: 0.0,
44        }
45    }
46}
47
48impl Modulate {
49    /// A modulation that changes nothing.
50    #[must_use]
51    pub fn identity() -> Self {
52        Self::default()
53    }
54
55    /// Scale brightness by `factor`.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`PixelsError::InvalidArgument`] if `factor` is negative or not
60    /// finite — a NaN multiplier would propagate silently into every pixel.
61    pub fn with_brightness(mut self, factor: f32) -> Result<Self> {
62        self.brightness = check_factor("brightness", factor)?;
63        Ok(self)
64    }
65
66    /// Scale saturation by `factor`.
67    ///
68    /// # Errors
69    ///
70    /// As [`Modulate::with_brightness`].
71    pub fn with_saturation(mut self, factor: f32) -> Result<Self> {
72        self.saturation = check_factor("saturation", factor)?;
73        Ok(self)
74    }
75
76    /// Rotate hue by `degrees`.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`PixelsError::InvalidArgument`] if `degrees` is not finite.
81    pub fn with_hue(mut self, degrees: f32) -> Result<Self> {
82        if !degrees.is_finite() {
83            return Err(PixelsError::invalid_argument(
84                "hue",
85                format!("rotation must be finite, got {degrees}"),
86            ));
87        }
88        self.hue = degrees;
89        Ok(self)
90    }
91
92    /// Whether this modulation is the identity, and can be skipped entirely.
93    #[must_use]
94    pub fn is_identity(&self) -> bool {
95        self.brightness == 1.0 && self.saturation == 1.0 && self.hue % 360.0 == 0.0
96    }
97}
98
99/// Reject a multiplier that would poison every pixel it touched.
100fn check_factor(name: &'static str, factor: f32) -> Result<f32> {
101    if !factor.is_finite() || factor < 0.0 {
102        return Err(PixelsError::invalid_argument(
103            name,
104            format!("must be finite and non-negative, got {factor}"),
105        ));
106    }
107    Ok(factor)
108}
109
110/// Convert RGB in 0..=1 to HSV, with hue in degrees.
111fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
112    let max = r.max(g).max(b);
113    let min = r.min(g).min(b);
114    let delta = max - min;
115
116    let hue = if delta <= f32::EPSILON {
117        0.0
118    } else if max == r {
119        60.0 * (((g - b) / delta) % 6.0)
120    } else if max == g {
121        60.0 * ((b - r) / delta + 2.0)
122    } else {
123        60.0 * ((r - g) / delta + 4.0)
124    };
125    let saturation = if max <= f32::EPSILON {
126        0.0
127    } else {
128        delta / max
129    };
130    (hue, saturation, max)
131}
132
133/// The inverse of [`rgb_to_hsv`].
134fn hsv_to_rgb(hue: f32, saturation: f32, value: f32) -> (f32, f32, f32) {
135    let hue = hue.rem_euclid(360.0);
136    let c = value * saturation;
137    let x = c * (1.0 - (((hue / 60.0) % 2.0) - 1.0).abs());
138    let m = value - c;
139    let (r, g, b) = match (hue / 60.0) as u32 {
140        0 => (c, x, 0.0),
141        1 => (x, c, 0.0),
142        2 => (0.0, c, x),
143        3 => (0.0, x, c),
144        4 => (x, 0.0, c),
145        _ => (c, 0.0, x),
146    };
147    (r + m, g + m, b + m)
148}
149
150/// Apply the modulation to one colour, in 0..=1.
151fn modulate_rgb(m: &Modulate, r: f32, g: f32, b: f32) -> (f32, f32, f32) {
152    let (hue, saturation, value) = rgb_to_hsv(r, g, b);
153    hsv_to_rgb(
154        hue + m.hue,
155        (saturation * m.saturation).clamp(0.0, 1.0),
156        (value * m.brightness).clamp(0.0, 1.0),
157    )
158}
159
160impl Op for Modulate {
161    /// Pointwise: every output pixel depends on the one input pixel beneath
162    /// it, with no length or coordinate anywhere, so resolution is irrelevant
163    /// to what this op means, and there is no bound state to discard.
164    fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
165        Some(std::sync::Arc::new(*self))
166    }
167    fn name(&self) -> &'static str {
168        "modulate"
169    }
170
171    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
172        let input = sole(self.name(), inputs)?;
173        Ok(*input)
174    }
175
176    fn input_regions(&self, output: Region, _inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
177        Ok(vec![output])
178    }
179
180    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
181        let input = sole_tile(self.name(), inputs)?;
182        let format = output.pixel();
183        let region = output.region();
184        let channels = format.channels();
185        let colour = format.layout();
186
187        for y in region.y..region.y + region.height {
188            let Some(source) = input.row(y) else { continue };
189            let Some(target) = output.row_mut(y) else {
190                continue;
191            };
192            match format.sample_kind() {
193                SampleKind::U8 => modulate_row_u8(self, source, target, channels, colour),
194                SampleKind::U16 => modulate_row_u16(self, source, target, channels, colour),
195                SampleKind::F32 => modulate_row_f32(self, source, target, channels, colour),
196            }
197        }
198        Ok(())
199    }
200}
201
202/// Whether a layout carries an alpha channel that must pass through untouched.
203const fn has_alpha(layout: ChannelLayout) -> bool {
204    matches!(layout, ChannelLayout::GrayAlpha | ChannelLayout::Rgba)
205}
206
207/// Whether a layout is greyscale, for which hue and saturation are meaningless.
208const fn is_gray(layout: ChannelLayout) -> bool {
209    matches!(layout, ChannelLayout::Gray | ChannelLayout::GrayAlpha)
210}
211
212fn modulate_row_u8(
213    m: &Modulate,
214    source: &[u8],
215    target: &mut [u8],
216    channels: usize,
217    layout: ChannelLayout,
218) {
219    let colour_channels = if has_alpha(layout) {
220        channels - 1
221    } else {
222        channels
223    };
224    for (from, to) in source
225        .chunks_exact(channels)
226        .zip(target.chunks_exact_mut(channels))
227    {
228        if is_gray(layout) {
229            // Hue and saturation have no meaning on one channel; brightness
230            // still does, and silently ignoring it would be surprising.
231            let value = f32::from(from.first().copied().unwrap_or(0)) / 255.0;
232            let scaled = (value * m.brightness).clamp(0.0, 1.0);
233            if let Some(slot) = to.first_mut() {
234                *slot = (scaled * 255.0 + 0.5) as u8;
235            }
236        } else {
237            let r = f32::from(from.first().copied().unwrap_or(0)) / 255.0;
238            let g = f32::from(from.get(1).copied().unwrap_or(0)) / 255.0;
239            let b = f32::from(from.get(2).copied().unwrap_or(0)) / 255.0;
240            let (r, g, b) = modulate_rgb(m, r, g, b);
241            for (index, value) in [r, g, b].into_iter().enumerate().take(colour_channels) {
242                if let Some(slot) = to.get_mut(index) {
243                    *slot = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
244                }
245            }
246        }
247        // Alpha is unassociated (SPEC §Formats), so it passes through.
248        if has_alpha(layout) {
249            if let (Some(alpha), Some(slot)) = (from.get(channels - 1), to.get_mut(channels - 1)) {
250                *slot = *alpha;
251            }
252        }
253    }
254}
255
256fn modulate_row_u16(
257    m: &Modulate,
258    source: &[u8],
259    target: &mut [u8],
260    channels: usize,
261    layout: ChannelLayout,
262) {
263    let pixel_bytes = channels * 2;
264    for (from, to) in source
265        .chunks_exact(pixel_bytes)
266        .zip(target.chunks_exact_mut(pixel_bytes))
267    {
268        let read = |index: usize| -> f32 {
269            let at = index * 2;
270            let value = u16::from_ne_bytes([
271                from.get(at).copied().unwrap_or(0),
272                from.get(at + 1).copied().unwrap_or(0),
273            ]);
274            f32::from(value) / 65535.0
275        };
276        let mut write = |index: usize, value: f32| {
277            let scaled = (value.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
278            let at = index * 2;
279            for (offset, byte) in scaled.to_ne_bytes().iter().enumerate() {
280                if let Some(slot) = to.get_mut(at + offset) {
281                    *slot = *byte;
282                }
283            }
284        };
285
286        if is_gray(layout) {
287            write(0, read(0) * m.brightness);
288        } else {
289            let (r, g, b) = modulate_rgb(m, read(0), read(1), read(2));
290            write(0, r);
291            write(1, g);
292            write(2, b);
293        }
294        if has_alpha(layout) {
295            let alpha = channels - 1;
296            let at = alpha * 2;
297            for offset in 0..2 {
298                if let (Some(byte), Some(slot)) = (from.get(at + offset), to.get_mut(at + offset)) {
299                    *slot = *byte;
300                }
301            }
302        }
303    }
304}
305
306fn modulate_row_f32(
307    m: &Modulate,
308    source: &[u8],
309    target: &mut [u8],
310    channels: usize,
311    layout: ChannelLayout,
312) {
313    let pixel_bytes = channels * 4;
314    for (from, to) in source
315        .chunks_exact(pixel_bytes)
316        .zip(target.chunks_exact_mut(pixel_bytes))
317    {
318        let read = |index: usize| -> f32 {
319            let at = index * 4;
320            let mut bytes = [0_u8; 4];
321            for (slot, offset) in bytes.iter_mut().zip(0..4) {
322                *slot = from.get(at + offset).copied().unwrap_or(0);
323            }
324            f32::from_ne_bytes(bytes)
325        };
326        let mut write = |index: usize, value: f32| {
327            let at = index * 4;
328            for (offset, byte) in value.to_ne_bytes().iter().enumerate() {
329                if let Some(slot) = to.get_mut(at + offset) {
330                    *slot = *byte;
331                }
332            }
333        };
334
335        if is_gray(layout) {
336            write(0, read(0) * m.brightness);
337        } else {
338            let (r, g, b) = modulate_rgb(m, read(0), read(1), read(2));
339            write(0, r);
340            write(1, g);
341            write(2, b);
342        }
343        if has_alpha(layout) {
344            let at = (channels - 1) * 4;
345            for offset in 0..4 {
346                if let (Some(byte), Some(slot)) = (from.get(at + offset), to.get_mut(at + offset)) {
347                    *slot = *byte;
348                }
349            }
350        }
351    }
352}
353
354/// Extract one channel as a greyscale image (SPEC §Core ops).
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
356pub struct ExtractChannel {
357    index: usize,
358}
359
360impl ExtractChannel {
361    /// Extract channel `index`, counted from zero in memory order.
362    ///
363    /// The index is validated against the actual input when chained, since the
364    /// channel count is not known here.
365    #[must_use]
366    pub const fn new(index: usize) -> Self {
367        Self { index }
368    }
369
370    /// The channel this op extracts.
371    #[must_use]
372    pub const fn index(&self) -> usize {
373        self.index
374    }
375}
376
377impl Op for ExtractChannel {
378    /// Pointwise: every output pixel depends on the one input pixel beneath
379    /// it, with no length or coordinate anywhere, so resolution is irrelevant
380    /// to what this op means, and there is no bound state to discard.
381    fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
382        Some(std::sync::Arc::new(*self))
383    }
384    fn name(&self) -> &'static str {
385        "extract_channel"
386    }
387
388    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
389        let input = sole(self.name(), inputs)?;
390        if self.index >= input.pixel.channels() {
391            return Err(PixelsError::invalid_argument(
392                "index",
393                format!(
394                    "channel {} does not exist in {} ({} channels)",
395                    self.index,
396                    input.pixel,
397                    input.pixel.channels()
398                ),
399            ));
400        }
401        let gray = match input.pixel.sample_kind() {
402            SampleKind::U8 => PixelFormat::Gray8,
403            SampleKind::U16 => PixelFormat::Gray16,
404            // There is no single-channel float format in v1, so extraction
405            // from a float image is refused rather than silently widened.
406            SampleKind::F32 => {
407                return Err(PixelsError::unsupported(format!(
408                    "cannot extract a channel from {}: v1 has no float greyscale format",
409                    input.pixel
410                )));
411            }
412        };
413        let mut out = *input;
414        out.pixel = gray;
415        Ok(out)
416    }
417
418    fn input_regions(&self, output: Region, _inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
419        Ok(vec![output])
420    }
421
422    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
423        let input = sole_tile(self.name(), inputs)?;
424        let region = output.region();
425        let in_channels = input.pixel().channels();
426        let sample = input.pixel().sample_kind().size();
427
428        for y in region.y..region.y + region.height {
429            let Some(source) = input.row(y) else { continue };
430            let Some(target) = output.row_mut(y) else {
431                continue;
432            };
433            for (pixel, slot) in source
434                .chunks_exact(in_channels * sample)
435                .zip(target.chunks_exact_mut(sample))
436            {
437                let at = self.index * sample;
438                let Some(from) = pixel.get(at..at + sample) else {
439                    continue;
440                };
441                slot.copy_from_slice(from);
442            }
443        }
444        Ok(())
445    }
446}
447
448/// Composite an image onto an opaque background, discarding alpha.
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
450pub struct Flatten {
451    background: [u8; 3],
452}
453
454impl Flatten {
455    /// Flatten onto an 8-bit RGB background.
456    #[must_use]
457    pub const fn onto(red: u8, green: u8, blue: u8) -> Self {
458        Self {
459            background: [red, green, blue],
460        }
461    }
462
463    /// Flatten onto black, the usual default.
464    #[must_use]
465    pub const fn black() -> Self {
466        Self::onto(0, 0, 0)
467    }
468
469    /// The background colour.
470    #[must_use]
471    pub const fn background(&self) -> [u8; 3] {
472        self.background
473    }
474}
475
476impl Op for Flatten {
477    /// Pointwise: every output pixel depends on the one input pixel beneath
478    /// it, with no length or coordinate anywhere, so resolution is irrelevant
479    /// to what this op means, and there is no bound state to discard.
480    fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
481        Some(std::sync::Arc::new(*self))
482    }
483    fn name(&self) -> &'static str {
484        "flatten"
485    }
486
487    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
488        let input = sole(self.name(), inputs)?;
489        let opaque = match input.pixel {
490            PixelFormat::GrayA8 => PixelFormat::Gray8,
491            PixelFormat::Rgba8 => PixelFormat::Rgb8,
492            PixelFormat::Rgba16 => PixelFormat::Rgb16,
493            PixelFormat::RgbaF32 => PixelFormat::RgbF32,
494            // Already opaque: flatten is a no-op rather than an error, so it
495            // can sit unconditionally in a pipeline.
496            other => other,
497        };
498        let mut out = *input;
499        out.pixel = opaque;
500        Ok(out)
501    }
502
503    fn input_regions(&self, output: Region, _inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
504        Ok(vec![output])
505    }
506
507    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
508        let input = sole_tile(self.name(), inputs)?;
509        let region = output.region();
510        let in_format = input.pixel();
511        let out_format = output.pixel();
512
513        if in_format == out_format {
514            // Nothing to composite; copy through.
515            for y in region.y..region.y + region.height {
516                let (Some(source), Some(target)) = (input.row(y), output.row_mut(y)) else {
517                    continue;
518                };
519                let len = target.len().min(source.len());
520                if let (Some(from), Some(to)) = (source.get(..len), target.get_mut(..len)) {
521                    to.copy_from_slice(from);
522                }
523            }
524            return Ok(());
525        }
526
527        if in_format.sample_kind() != SampleKind::U8 {
528            return Err(PixelsError::unsupported(format!(
529                "flatten is implemented for 8-bit input; got {in_format}"
530            )));
531        }
532
533        let in_channels = in_format.channels();
534        let out_channels = out_format.channels();
535        for y in region.y..region.y + region.height {
536            let (Some(source), Some(target)) = (input.row(y), output.row_mut(y)) else {
537                continue;
538            };
539            for (pixel, slot) in source
540                .chunks_exact(in_channels)
541                .zip(target.chunks_exact_mut(out_channels))
542            {
543                let alpha = u32::from(pixel.get(in_channels - 1).copied().unwrap_or(255));
544                for channel in 0..out_channels {
545                    let foreground = u32::from(pixel.get(channel).copied().unwrap_or(0));
546                    let background = u32::from(self.background.get(channel).copied().unwrap_or(0));
547                    // Straight-alpha source-over, rounded rather than
548                    // truncated: `(f*a + b*(255-a) + 127) / 255`.
549                    let blended = (foreground * alpha + background * (255 - alpha) + 127) / 255;
550                    if let Some(out) = slot.get_mut(channel) {
551                        *out = blended as u8;
552                    }
553                }
554            }
555        }
556        Ok(())
557    }
558}
559
560/// Fetch the sole input descriptor an op was given.
561fn sole<'a>(op: &str, inputs: &'a [ImageDescriptor]) -> Result<&'a ImageDescriptor> {
562    inputs
563        .first()
564        .ok_or_else(|| PixelsError::graph(format!("`{op}` takes one input, got none")))
565}
566
567/// Fetch the sole input tile an op was given.
568fn sole_tile<'a, 'b>(op: &str, inputs: &'a [Tile<'b>]) -> Result<&'a Tile<'b>> {
569    inputs
570        .first()
571        .ok_or_else(|| PixelsError::graph(format!("`{op}` takes one input tile, got none")))
572}
573
574#[cfg(test)]
575#[allow(
576    clippy::unwrap_used,
577    clippy::expect_used,
578    clippy::indexing_slicing,
579    clippy::panic,
580    reason = "tests operate on known-good values and assert shapes directly"
581)]
582mod tests {
583    use super::*;
584    use otf_pixels_core::TileBuf;
585
586    /// Run an op over a whole image.
587    fn apply(
588        op: &dyn Op,
589        input: &ImageDescriptor,
590        bytes: &[u8],
591    ) -> Result<(ImageDescriptor, Vec<u8>)> {
592        let out_desc = op.output_descriptor(std::slice::from_ref(input))?;
593        let source = TileBuf::from_vec(input.region(), input.pixel, bytes.to_vec())?;
594        let mut target = TileBuf::for_image(&out_desc)?;
595        op.compute(&[source.as_tile()?], &mut target.as_tile_mut()?)?;
596        Ok((out_desc, target.into_bytes()))
597    }
598
599    fn image(
600        width: u32,
601        height: u32,
602        format: PixelFormat,
603        fill: &[u8],
604    ) -> (ImageDescriptor, Vec<u8>) {
605        let descriptor = ImageDescriptor::new(width, height, format).unwrap();
606        let len = descriptor.byte_len().unwrap();
607        let bytes = (0..len).map(|i| fill[i % fill.len()]).collect();
608        (descriptor, bytes)
609    }
610
611    // -----------------------------------------------------------------
612    // Modulate
613    // -----------------------------------------------------------------
614
615    #[test]
616    fn the_identity_modulation_changes_nothing() {
617        // The most important property of a colour op: doing nothing must
618        // really do nothing, including no rounding drift.
619        for format in [
620            PixelFormat::Gray8,
621            PixelFormat::Rgb8,
622            PixelFormat::Rgba8,
623            PixelFormat::Rgb16,
624            PixelFormat::Rgba16,
625        ] {
626            let (desc, bytes) = image(8, 8, format, &[13, 200, 77, 255, 4, 91]);
627            let (_, out) = apply(&Modulate::identity(), &desc, &bytes).unwrap();
628            assert_eq!(out, bytes, "{format} changed under the identity");
629        }
630    }
631
632    #[test]
633    fn hsv_round_trips_for_every_colour() {
634        // The conversion is the substance of modulate; if it does not round
635        // trip, every adjustment is wrong in a way that looks like a filter.
636        for r in 0..8 {
637            for g in 0..8 {
638                for b in 0..8 {
639                    let (rf, gf, bf) = (r as f32 / 7.0, g as f32 / 7.0, b as f32 / 7.0);
640                    let (h, s, v) = rgb_to_hsv(rf, gf, bf);
641                    let (r2, g2, b2) = hsv_to_rgb(h, s, v);
642                    assert!(
643                        (rf - r2).abs() < 1e-4 && (gf - g2).abs() < 1e-4 && (bf - b2).abs() < 1e-4,
644                        "({rf},{gf},{bf}) -> ({h},{s},{v}) -> ({r2},{g2},{b2})"
645                    );
646                }
647            }
648        }
649    }
650
651    #[test]
652    fn zero_saturation_produces_grey() {
653        let (desc, bytes) = image(4, 4, PixelFormat::Rgb8, &[200, 50, 30]);
654        let m = Modulate::identity().with_saturation(0.0).unwrap();
655        let (_, out) = apply(&m, &desc, &bytes).unwrap();
656        for pixel in out.chunks_exact(3) {
657            assert_eq!(pixel[0], pixel[1], "not grey: {pixel:?}");
658            assert_eq!(pixel[1], pixel[2], "not grey: {pixel:?}");
659        }
660    }
661
662    #[test]
663    fn brightness_scales_and_saturates_rather_than_wrapping() {
664        // A doubled bright pixel must clamp to white, not wrap to black.
665        let (desc, bytes) = image(4, 4, PixelFormat::Rgb8, &[200, 200, 200]);
666        let m = Modulate::identity().with_brightness(2.0).unwrap();
667        let (_, out) = apply(&m, &desc, &bytes).unwrap();
668        assert!(
669            out.iter().all(|&v| v == 255),
670            "expected white, got {:?}",
671            &out[..3]
672        );
673
674        let m = Modulate::identity().with_brightness(0.5).unwrap();
675        let (_, dim) = apply(&m, &desc, &bytes).unwrap();
676        assert!(
677            dim.iter().all(|&v| v == 100),
678            "expected 100, got {:?}",
679            &dim[..3]
680        );
681    }
682
683    #[test]
684    fn a_full_hue_rotation_is_the_identity() {
685        let (desc, bytes) = image(4, 4, PixelFormat::Rgb8, &[200, 50, 30]);
686        let m = Modulate::identity().with_hue(360.0).unwrap();
687        let (_, out) = apply(&m, &desc, &bytes).unwrap();
688        for (a, b) in out.iter().zip(&bytes) {
689            assert!(a.abs_diff(*b) <= 1, "360 degrees changed {b} to {a}");
690        }
691    }
692
693    #[test]
694    fn alpha_passes_through_untouched() {
695        // SPEC §Formats: alpha is unassociated, so a colour op must not
696        // scale it. Scaling it here would silently premultiply.
697        let (desc, bytes) = image(4, 4, PixelFormat::Rgba8, &[200, 50, 30, 137]);
698        let m = Modulate::identity()
699            .with_brightness(0.25)
700            .unwrap()
701            .with_saturation(2.0)
702            .unwrap();
703        let (_, out) = apply(&m, &desc, &bytes).unwrap();
704        for pixel in out.chunks_exact(4) {
705            assert_eq!(pixel[3], 137, "alpha was modulated");
706        }
707    }
708
709    #[test]
710    fn greyscale_takes_brightness_but_not_hue() {
711        let (desc, bytes) = image(4, 4, PixelFormat::Gray8, &[100]);
712        let m = Modulate::identity()
713            .with_brightness(0.5)
714            .unwrap()
715            .with_hue(180.0)
716            .unwrap();
717        let (_, out) = apply(&m, &desc, &bytes).unwrap();
718        assert!(out.iter().all(|&v| v == 50), "got {:?}", &out[..4]);
719    }
720
721    #[test]
722    fn a_non_finite_factor_is_an_error_not_a_poisoned_image() {
723        assert!(Modulate::identity().with_brightness(f32::NAN).is_err());
724        assert!(Modulate::identity().with_brightness(f32::INFINITY).is_err());
725        assert!(Modulate::identity().with_brightness(-1.0).is_err());
726        assert!(Modulate::identity().with_saturation(f32::NAN).is_err());
727        assert!(Modulate::identity().with_hue(f32::NAN).is_err());
728    }
729
730    // -----------------------------------------------------------------
731    // ExtractChannel
732    // -----------------------------------------------------------------
733
734    #[test]
735    fn extracting_a_channel_gives_that_channel() {
736        let (desc, bytes) = image(4, 4, PixelFormat::Rgba8, &[10, 20, 30, 40]);
737        for index in 0..4 {
738            let (out_desc, out) = apply(&ExtractChannel::new(index), &desc, &bytes).unwrap();
739            assert_eq!(out_desc.pixel, PixelFormat::Gray8);
740            let expected = (index as u8 + 1) * 10;
741            assert!(
742                out.iter().all(|&v| v == expected),
743                "channel {index} gave {:?}",
744                &out[..4]
745            );
746        }
747    }
748
749    #[test]
750    fn extracting_sixteen_bit_channels_preserves_precision() {
751        let descriptor = ImageDescriptor::new(4, 4, PixelFormat::Rgb16).unwrap();
752        let mut bytes = Vec::new();
753        for _ in 0..16 {
754            for value in [1000_u16, 30000, 65535] {
755                bytes.extend_from_slice(&value.to_ne_bytes());
756            }
757        }
758        let (out_desc, out) = apply(&ExtractChannel::new(1), &descriptor, &bytes).unwrap();
759        assert_eq!(out_desc.pixel, PixelFormat::Gray16);
760        for pair in out.chunks_exact(2) {
761            assert_eq!(u16::from_ne_bytes([pair[0], pair[1]]), 30000);
762        }
763    }
764
765    #[test]
766    fn extracting_a_channel_that_does_not_exist_is_an_error() {
767        let descriptor = ImageDescriptor::new(4, 4, PixelFormat::Rgb8).unwrap();
768        let error = ExtractChannel::new(3)
769            .output_descriptor(std::slice::from_ref(&descriptor))
770            .unwrap_err();
771        assert!(error.to_string().contains("does not exist"), "{error}");
772    }
773
774    #[test]
775    fn extracting_from_a_float_image_is_unsupported_not_wrong() {
776        // v1 has no float greyscale format, so this is refused rather than
777        // quietly widened to RGB.
778        let descriptor = ImageDescriptor::new(4, 4, PixelFormat::RgbF32).unwrap();
779        assert!(
780            ExtractChannel::new(0)
781                .output_descriptor(std::slice::from_ref(&descriptor))
782                .is_err()
783        );
784    }
785
786    // -----------------------------------------------------------------
787    // Flatten
788    // -----------------------------------------------------------------
789
790    #[test]
791    fn flattening_an_opaque_pixel_keeps_its_colour() {
792        let (desc, bytes) = image(4, 4, PixelFormat::Rgba8, &[200, 100, 50, 255]);
793        let (out_desc, out) = apply(&Flatten::black(), &desc, &bytes).unwrap();
794        assert_eq!(out_desc.pixel, PixelFormat::Rgb8);
795        for pixel in out.chunks_exact(3) {
796            assert_eq!(pixel, [200, 100, 50], "opaque pixel changed");
797        }
798    }
799
800    #[test]
801    fn flattening_a_transparent_pixel_gives_the_background() {
802        let (desc, bytes) = image(4, 4, PixelFormat::Rgba8, &[200, 100, 50, 0]);
803        let (_, out) = apply(&Flatten::onto(9, 8, 7), &desc, &bytes).unwrap();
804        for pixel in out.chunks_exact(3) {
805            assert_eq!(
806                pixel,
807                [9, 8, 7],
808                "transparent pixel did not take the background"
809            );
810        }
811    }
812
813    #[test]
814    fn flattening_at_half_alpha_is_the_midpoint() {
815        // 128/255 is just over half, so a 0-and-255 blend rounds to 128.
816        let (desc, bytes) = image(4, 4, PixelFormat::Rgba8, &[255, 255, 255, 128]);
817        let (_, out) = apply(&Flatten::black(), &desc, &bytes).unwrap();
818        for pixel in out.chunks_exact(3) {
819            assert_eq!(pixel, [128, 128, 128], "blend is not the midpoint");
820        }
821    }
822
823    #[test]
824    fn flattening_an_image_without_alpha_is_a_pass_through() {
825        // Flatten must be safe to put in a pipeline unconditionally.
826        let (desc, bytes) = image(4, 4, PixelFormat::Rgb8, &[1, 2, 3]);
827        let (out_desc, out) = apply(&Flatten::onto(200, 200, 200), &desc, &bytes).unwrap();
828        assert_eq!(out_desc.pixel, PixelFormat::Rgb8);
829        assert_eq!(out, bytes);
830    }
831
832    #[test]
833    fn flattening_greyscale_alpha_gives_greyscale() {
834        let (desc, bytes) = image(4, 4, PixelFormat::GrayA8, &[255, 0]);
835        let (out_desc, out) = apply(&Flatten::onto(30, 0, 0), &desc, &bytes).unwrap();
836        assert_eq!(out_desc.pixel, PixelFormat::Gray8);
837        assert!(out.iter().all(|&v| v == 30), "got {:?}", &out[..4]);
838    }
839}