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