1use image::{DynamicImage, GrayAlphaImage, RgbaImage};
10use moxcms::{ColorProfile, DataColorSpace, Layout, TransformExecutor, TransformOptions};
11
12const 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
24pub 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
52pub 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
70fn unchanged(a: &[u8], b: &[u8]) -> bool {
72 a.iter().zip(b).all(|(x, y)| x.abs_diff(*y) <= 1)
73}
74
75fn 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 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}