Skip to main content

falcon_library/
color.rs

1//! Turns a picture's pixels from its embedded ICC profile into sRGB for
2//! showing. Falcon draws sRGB, so a picture tagged Adobe RGB or Display P3
3//! would otherwise look dull. Colours outside sRGB are clipped.
4//!
5//! This is for what's on screen and in thumbnails. Rotating, flipping,
6//! cropping and exporting rewrite the file's own pixels, so they keep
7//! [`crate::thumbnail::decode`], which leaves them alone.
8
9use image::{DynamicImage, GrayAlphaImage, RgbaImage};
10use moxcms::{ColorProfile, DataColorSpace, Layout, TransformExecutor, TransformOptions};
11
12/// Colours compared to tell a profile that changes nothing, such as sRGB's own.
13const PROBE: [[u8; 4]; 8] = [
14    [0, 0, 0, 255],
15    [255, 255, 255, 255],
16    [128, 128, 128, 255],
17    [200, 60, 40, 255],
18    [40, 200, 60, 255],
19    [40, 60, 200, 255],
20    [64, 32, 16, 255],
21    [230, 200, 120, 255],
22];
23
24/// `img` in sRGB through `icc`. A profile that can't be read, doesn't match
25/// the picture's colours or changes nothing leaves `img` as it is.
26pub fn to_srgb(img: DynamicImage, icc: &[u8]) -> DynamicImage {
27    let Ok(profile) = ColorProfile::new_from_slice(icc) else { return img };
28    let srgb = ColorProfile::new_srgb();
29    let options = TransformOptions::default();
30    let gray = !img.color().has_color();
31    match profile.color_space {
32        DataColorSpace::Rgb if !gray && !is_float(&img) => {
33            let Ok(transform) = profile.create_transform_8bit(Layout::Rgba, &srgb, Layout::Rgba, options) else { return img };
34            let mut probe = [0u8; PROBE.len() * 4];
35            if transform.transform(PROBE.as_flattened(), &mut probe).is_err() || unchanged(PROBE.as_flattened(), &probe) {
36                return img;
37            }
38            let mut rgba = img.to_rgba8();
39            if !convert(&mut rgba, transform.as_ref()) {
40                return img;
41            }
42            DynamicImage::ImageRgba8(rgba)
43        }
44        DataColorSpace::Gray if gray && !is_float(&img) => {
45            let Ok(transform) = profile.create_transform_8bit(Layout::GrayAlpha, &srgb, Layout::Rgba, options) else { return img };
46            gray_to_srgb(&img.to_luma_alpha8(), transform.as_ref()).map_or(img, DynamicImage::ImageRgba8)
47        }
48        _ => img,
49    }
50}
51
52/// Whether showing a colour picture through `icc` changes it, so a caller
53/// with many frames can skip profiles that do nothing.
54pub fn changes_rgb(icc: &[u8]) -> bool {
55    let Ok(profile) = ColorProfile::new_from_slice(icc) else { return false };
56    if profile.color_space != DataColorSpace::Rgb {
57        return false;
58    }
59    let Ok(transform) = profile.create_transform_8bit(Layout::Rgba, &ColorProfile::new_srgb(), Layout::Rgba, TransformOptions::default()) else {
60        return false;
61    };
62    let mut probe = [0u8; PROBE.len() * 4];
63    transform.transform(PROBE.as_flattened(), &mut probe).is_ok() && !unchanged(PROBE.as_flattened(), &probe)
64}
65
66fn is_float(img: &DynamicImage) -> bool {
67    matches!(img, DynamicImage::ImageRgb32F(_) | DynamicImage::ImageRgba32F(_))
68}
69
70/// No sample moved by more than rounding.
71fn unchanged(a: &[u8], b: &[u8]) -> bool {
72    a.iter().zip(b).all(|(x, y)| x.abs_diff(*y) <= 1)
73}
74
75/// Runs `transform` over `rgba` in place, a band of rows per thread.
76fn convert(rgba: &mut RgbaImage, transform: &(dyn TransformExecutor<u8> + Send + Sync)) -> bool {
77    let row = rgba.width() as usize * 4;
78    if row == 0 {
79        return true;
80    }
81    let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
82    let band = (rgba.height() as usize).div_ceil(threads).max(1) * row;
83    std::thread::scope(|s| {
84        let workers: Vec<_> = rgba
85            .chunks_mut(band)
86            .map(|pixels| {
87                s.spawn(move || {
88                    let source = pixels.to_vec();
89                    transform.transform(&source, pixels).is_ok()
90                })
91            })
92            .collect();
93        workers.into_iter().all(|w| w.join().unwrap_or(false))
94    })
95}
96
97fn gray_to_srgb(gray: &GrayAlphaImage, transform: &(dyn TransformExecutor<u8> + Send + Sync)) -> Option<RgbaImage> {
98    let mut rgba = vec![0u8; gray.width() as usize * gray.height() as usize * 4];
99    transform.transform(gray.as_raw(), &mut rgba).ok()?;
100    RgbaImage::from_raw(gray.width(), gray.height(), rgba)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use image::{GrayImage, Luma, Rgba};
107
108    fn pixel(img: &DynamicImage) -> [u8; 4] {
109        img.to_rgba8().get_pixel(0, 0).0
110    }
111
112    fn solid(c: [u8; 4]) -> DynamicImage {
113        DynamicImage::ImageRgba8(RgbaImage::from_pixel(3, 5, Rgba(c)))
114    }
115
116    #[test]
117    fn a_wider_profile_moves_colours_as_srgb_would_show_them() {
118        let icc = ColorProfile::new_adobe_rgb().encode().unwrap();
119        let [r, g, b, a] = pixel(&to_srgb(solid([128, 200, 128, 200]), &icc));
120        assert!(r < 128 && g > 200, "wider gamut pushes green out: {r} {g} {b}");
121        assert_eq!(a, 200);
122    }
123
124    #[test]
125    fn display_p3_lands_on_its_srgb_value() {
126        let icc = ColorProfile::new_display_p3().encode().unwrap();
127        let [r, g, b, _] = pixel(&to_srgb(solid([200, 100, 50, 255]), &icc));
128        // Display P3 (200, 100, 50) through ColorSync, from `sips -m`: (215, 93, 31).
129        assert!(r.abs_diff(215) <= 2 && g.abs_diff(93) <= 2 && b.abs_diff(31) <= 3, "{r} {g} {b}");
130    }
131
132    #[test]
133    fn every_row_is_converted_whatever_the_thread_count() {
134        let icc = ColorProfile::new_display_p3().encode().unwrap();
135        let out = to_srgb(DynamicImage::ImageRgba8(RgbaImage::from_pixel(7, 301, Rgba([200, 100, 50, 255]))), &icc).to_rgba8();
136        let first = *out.get_pixel(0, 0);
137        assert_ne!(first.0[0], 200);
138        assert!(out.pixels().all(|p| *p == first));
139    }
140
141    #[test]
142    fn srgb_keeps_the_picture_as_it_is() {
143        let icc = ColorProfile::new_srgb().encode().unwrap();
144        let img = DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([200, 100, 50])));
145        let out = to_srgb(img, &icc);
146        assert!(matches!(out, DynamicImage::ImageRgb8(_)), "untouched, not rebuilt");
147        assert_eq!(pixel(&out), [200, 100, 50, 255]);
148    }
149
150    #[test]
151    fn only_a_wider_colour_profile_counts_as_a_change() {
152        assert!(changes_rgb(&ColorProfile::new_display_p3().encode().unwrap()));
153        assert!(!changes_rgb(&ColorProfile::new_srgb().encode().unwrap()));
154        assert!(!changes_rgb(&ColorProfile::new_gray_with_gamma(1.0).encode().unwrap()));
155        assert!(!changes_rgb(b"not a profile"));
156    }
157
158    #[test]
159    fn an_unreadable_profile_changes_nothing() {
160        assert_eq!(pixel(&to_srgb(solid([200, 100, 50, 255]), b"not a profile")), [200, 100, 50, 255]);
161    }
162
163    #[test]
164    fn a_profile_for_other_colours_changes_nothing() {
165        let icc = ColorProfile::new_gray_with_gamma(1.0).encode().unwrap();
166        assert_eq!(pixel(&to_srgb(solid([200, 100, 50, 255]), &icc)), [200, 100, 50, 255]);
167    }
168
169    #[test]
170    fn a_linear_gray_profile_brightens_midtones() {
171        let icc = ColorProfile::new_gray_with_gamma(1.0).encode().unwrap();
172        let img = DynamicImage::ImageLuma8(GrayImage::from_pixel(2, 2, Luma([64])));
173        let [r, g, b, a] = pixel(&to_srgb(img, &icc));
174        assert!(r > 100 && r == g && g == b, "{r} {g} {b}");
175        assert_eq!(a, 255);
176    }
177}