Krolyxon
Branch master

Branches

Tags

Refs →

remagesrcdecodecolor.rs

333 lines · 11.9 KB · Rust

1//! Colour to sRGB (ICC and CICP through moxcms), HDR tone-mapping and EXIF orientation.
2
3use image::DynamicImage;
4use image::metadata::Orientation;
5use moxcms::{
6 CicpColorPrimaries, CicpProfile, ColorProfile, DataColorSpace, Layout, MatrixCoefficients,
7 TransferCharacteristics, TransformOptions,
8};
9
10/// An sRGB-bound transform for RGBA8 pixels, or `None` when the source already is sRGB (or
11/// the profile can't be used, in which case the pixels are shown as they are).
12pub struct ToSrgb(std::sync::Arc<moxcms::Transform8BitExecutor>);
13
14impl ToSrgb {
15 pub fn from_icc(icc: &[u8]) -> Option<Self> {
16 let src = match ColorProfile::new_from_slice(icc) {
17 Ok(p) => p,
18 Err(e) => {
19 log::debug!("ICC profile ignored: {e:?}");
20 return None;
21 }
22 };
23 if src.color_space != DataColorSpace::Rgb {
24 return None;
25 }
26 Self::from_profile(&src)
27 }
28
29 /// From H.273 code points (AVIF/HEIF `nclx`). sRGB/BT.709 and unspecified need nothing.
30 #[cfg_attr(not(feature = "heif"), allow(dead_code))]
31 pub fn from_cicp(primaries: u8, transfer: u8) -> Option<Self> {
32 let primaries = CicpColorPrimaries::try_from(primaries).ok()?;
33 if matches!(
34 primaries,
35 CicpColorPrimaries::Bt709 | CicpColorPrimaries::Unspecified
36 ) {
37 return None;
38 }
39 let transfer = TransferCharacteristics::try_from(transfer)
40 .ok()
41 .filter(|t| !matches!(t, TransferCharacteristics::Unspecified))
42 .unwrap_or(TransferCharacteristics::Srgb);
43 let profile = ColorProfile::new_from_cicp(CicpProfile {
44 color_primaries: primaries,
45 transfer_characteristics: transfer,
46 matrix_coefficients: MatrixCoefficients::Identity,
47 full_range: true,
48 });
49 Self::from_profile(&profile)
50 }
51
52 fn from_profile(src: &ColorProfile) -> Option<Self> {
53 let dst = ColorProfile::new_srgb();
54 match src.create_transform_8bit(
55 Layout::Rgba,
56 &dst,
57 Layout::Rgba,
58 TransformOptions::default(),
59 ) {
60 Ok(t) => {
61 let t = Self(t);
62 // Most tagged files carry some flavour of the sRGB profile.
63 (!t.is_identity()).then_some(t)
64 }
65 Err(e) => {
66 log::debug!("colour transform unavailable: {e:?}");
67 None
68 }
69 }
70 }
71
72 /// Whether the transform leaves colours alone, to within rounding (an sRGB source).
73 fn is_identity(&self) -> bool {
74 const STEPS: [u8; 9] = [0, 32, 64, 96, 128, 160, 192, 224, 255];
75 let mut src = Vec::with_capacity((STEPS.len().pow(3) + 256) * 4);
76 for r in STEPS {
77 for g in STEPS {
78 for b in STEPS {
79 src.extend([r, g, b, 255]);
80 }
81 }
82 }
83 for v in 0..=255 {
84 src.extend([v, v, v, 255]);
85 }
86 let mut dst = vec![0; src.len()];
87 self.0.transform(&src, &mut dst).is_ok()
88 && src.iter().zip(&dst).all(|(a, b)| a.abs_diff(*b) <= 1)
89 }
90
91 /// Convert RGBA8 pixels in place; alpha is untouched. Large images are split across a
92 /// few threads.
93 pub fn apply(&self, pixels: &mut [u8]) {
94 /// Pixels are converted through a scratch buffer of this many bytes.
95 const CHUNK: usize = 64 << 10;
96 /// Bytes that make another thread worth starting.
97 const PER_THREAD: usize = 4 << 20;
98 let band = |band: &mut [u8]| {
99 let mut scratch = [0u8; CHUNK];
100 for chunk in band.chunks_mut(CHUNK) {
101 let src = &mut scratch[..chunk.len()];
102 src.copy_from_slice(chunk);
103 if let Err(e) = self.0.transform(src, chunk) {
104 log::debug!("colour transform failed: {e:?}");
105 chunk.copy_from_slice(src);
106 }
107 }
108 };
109 let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
110 let threads = (pixels.len() / PER_THREAD).clamp(1, cores.min(4));
111 if threads == 1 {
112 return band(pixels);
113 }
114 let len = (pixels.len() / 4).div_ceil(threads) * 4;
115 std::thread::scope(|s| {
116 for part in pixels.chunks_mut(len) {
117 s.spawn(|| band(part));
118 }
119 });
120 }
121}
122
123fn srgb_encode(v: f32) -> f32 {
124 if v <= 0.003_130_8 {
125 v * 12.92
126 } else {
127 1.055 * v.powf(1.0 / 2.4) - 0.055
128 }
129}
130
131/// Narkowicz's ACES fit: filmic highlights, unchanged blacks.
132fn aces(x: f32) -> f32 {
133 ((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)).clamp(0.0, 1.0)
134}
135
136/// Linear float RGBA (EXR, Radiance HDR, float TIFF) to sRGB RGBA8. Images whose values stay
137/// within 0..1 are only encoded; brighter ones are tone-mapped.
138pub fn tonemap(rgba: &[f32]) -> Vec<u8> {
139 let peak = rgba
140 .as_chunks::<4>()
141 .0
142 .iter()
143 .flat_map(|p| &p[..3])
144 .copied()
145 .filter(|v| v.is_finite())
146 .fold(0.0f32, f32::max);
147 let hdr = peak > 1.0;
148 let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
149 let mut out = Vec::with_capacity(rgba.len());
150 for p in rgba.as_chunks::<4>().0.iter() {
151 for &c in &p[..3] {
152 let c = if c.is_finite() { c.max(0.0) } else { 0.0 };
153 let c = if hdr { aces(c) } else { c };
154 out.push(to8(srgb_encode(c)));
155 }
156 out.push(to8(if p[3].is_finite() { p[3] } else { 1.0 }));
157 }
158 out
159}
160
161/// Whether the image holds float samples that need tone-mapping rather than a plain cast.
162pub fn is_float(img: &DynamicImage) -> bool {
163 matches!(
164 img,
165 DynamicImage::ImageRgb32F(_) | DynamicImage::ImageRgba32F(_)
166 )
167}
168
169/// Straight RGBA8 from any decoded image, tone-mapping float data.
170pub fn to_rgba8(img: DynamicImage) -> ([u32; 2], Vec<u8>) {
171 let size = [img.width(), img.height()];
172 match img {
173 DynamicImage::ImageRgb8(rgb) => (size, expand(rgb.as_raw(), 3)),
174 img if is_float(&img) => (size, tonemap(img.into_rgba32f().as_raw())),
175 img => (size, img.into_rgba8().into_raw()),
176 }
177}
178
179/// Whether an orientation turns the image on its side, swapping width and height.
180pub fn swaps_axes(o: Orientation) -> bool {
181 use Orientation::*;
182 matches!(o, Rotate90 | Rotate270 | Rotate90FlipH | Rotate270FlipH)
183}
184
185/// Where stored pixels land in the upright image, as a map between unit squares:
186/// `upright = m * stored + t`. The renderer folds this into an image's placement, so
187/// rotated photos are never turned on the CPU.
188pub fn orient_uv(o: Orientation) -> ([f32; 4], [f32; 2]) {
189 use Orientation::*;
190 match o {
191 NoTransforms => ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0]),
192 Rotate90 => ([0.0, -1.0, 1.0, 0.0], [1.0, 0.0]),
193 Rotate180 => ([-1.0, 0.0, 0.0, -1.0], [1.0, 1.0]),
194 Rotate270 => ([0.0, 1.0, -1.0, 0.0], [0.0, 1.0]),
195 FlipHorizontal => ([-1.0, 0.0, 0.0, 1.0], [1.0, 0.0]),
196 FlipVertical => ([1.0, 0.0, 0.0, -1.0], [0.0, 1.0]),
197 Rotate90FlipH => ([0.0, 1.0, 1.0, 0.0], [0.0, 0.0]),
198 Rotate270FlipH => ([0.0, -1.0, -1.0, 0.0], [1.0, 1.0]),
199 }
200}
201
202/// Rotate/flip RGBA8 pixels upright.
203pub fn orient(size: [u32; 2], pixels: Vec<u8>, o: Orientation) -> ([u32; 2], Vec<u8>) {
204 if o == Orientation::NoTransforms {
205 return (size, pixels);
206 }
207 let Some(buf) = image::RgbaImage::from_raw(size[0], size[1], pixels) else {
208 return (size, Vec::new());
209 };
210 let mut img = DynamicImage::ImageRgba8(buf);
211 img.apply_orientation(o);
212 let out = img.into_rgba8();
213 ([out.width(), out.height()], out.into_raw())
214}
215
216/// RGBA8 from interleaved 8-bit samples with 1–4 channels (grey, grey+alpha, RGB, RGBA).
217pub fn expand(samples: &[u8], channels: usize) -> Vec<u8> {
218 match channels {
219 4 => samples.to_vec(),
220 3 => {
221 let mut out = vec![255; samples.len() / 3 * 4];
222 for (o, p) in out
223 .as_chunks_mut::<4>()
224 .0
225 .iter_mut()
226 .zip(samples.as_chunks::<3>().0)
227 {
228 o[..3].copy_from_slice(p);
229 }
230 out
231 }
232 2 => samples
233 .as_chunks::<2>()
234 .0
235 .iter()
236 .flat_map(|p| [p[0], p[0], p[0], p[1]])
237 .collect(),
238 _ => samples.iter().flat_map(|&g| [g, g, g, 255]).collect(),
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn tonemapping() {
248 // In-range linear data is only sRGB-encoded.
249 let px = tonemap(&[0.0, 0.2158, 1.0, 0.5]);
250 assert_eq!(px, [0, 128, 255, 128]);
251 // HDR highlights roll off instead of clipping, and stay ordered.
252 let px = tonemap(&[1.0, 4.0, 40.0, 1.0, f32::NAN, -1.0, 0.0, f32::INFINITY]);
253 assert!(px[0] < px[1] && px[1] <= px[2] && px[2] == 255, "{px:?}");
254 assert_eq!(&px[4..8], [0, 0, 0, 255]);
255 }
256
257 #[test]
258 fn orientation_turns_pixels() {
259 // 2×1: red, green. Rotate 90° clockwise → 1×2: red on top.
260 let px = vec![255, 0, 0, 255, 0, 255, 0, 255];
261 let (size, out) = orient([2, 1], px, Orientation::Rotate90);
262 assert_eq!(size, [1, 2]);
263 assert_eq!(&out[..4], [255, 0, 0, 255]);
264 let (size, out) = orient([2, 1], out.clone(), Orientation::NoTransforms);
265 assert_eq!((size, out.len()), ([2, 1], 8));
266 }
267
268 #[test]
269 fn gpu_orientation_matches_the_cpu() {
270 use Orientation::*;
271 // 3×2, every pixel distinct.
272 let (w, h) = (3u32, 2u32);
273 let px: Vec<u8> = (0..w * h).flat_map(|i| [i as u8, 0, 0, 255]).collect();
274 for o in [
275 NoTransforms,
276 Rotate90,
277 Rotate180,
278 Rotate270,
279 FlipHorizontal,
280 FlipVertical,
281 Rotate90FlipH,
282 Rotate270FlipH,
283 ] {
284 let ([uw, uh], upright) = orient([w, h], px.clone(), o);
285 assert_eq!(swaps_axes(o), uw == h, "{o:?}");
286 let (m, t) = orient_uv(o);
287 for y in 0..h {
288 for x in 0..w {
289 // The stored pixel's centre, mapped into the upright image.
290 let (u, v) = ((x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32);
291 let ux = ((m[0] * u + m[1] * v + t[0]) * uw as f32) as u32;
292 let uy = ((m[2] * u + m[3] * v + t[1]) * uh as f32) as u32;
293 let got = upright[((uy * uw + ux) * 4) as usize];
294 assert_eq!(got, (y * w + x) as u8, "{o:?} at {x},{y}");
295 }
296 }
297 }
298 }
299
300 #[test]
301 fn srgb_profiles_need_no_transform() {
302 let srgb = ColorProfile::new_srgb().encode().unwrap();
303 assert!(ToSrgb::from_icc(&srgb).is_none());
304 let p3 = ColorProfile::new_display_p3().encode().unwrap();
305 let t = ToSrgb::from_icc(&p3).expect("P3 transform");
306 // Threaded and single-threaded conversion agree, whatever the length.
307 let px: Vec<u8> = (0..(9 << 20) + 4).map(|i| (i * 7 % 256) as u8).collect();
308 let mut big = px.clone();
309 t.apply(&mut big);
310 for at in [0, 4 << 20, px.len() - 8] {
311 let mut small = px[at..at + 8].to_vec();
312 t.apply(&mut small);
313 assert_eq!(small, big[at..at + 8], "at {at}");
314 }
315 }
316
317 #[test]
318 fn cicp_and_icc() {
319 assert!(ToSrgb::from_cicp(1, 13).is_none(), "BT.709 is already sRGB");
320 // Display P3 primaries with the sRGB curve: pure P3 red is outside sRGB.
321 let t = ToSrgb::from_cicp(12, 13).expect("P3 transform");
322 let mut px = [255, 0, 0, 200];
323 t.apply(&mut px);
324 assert_eq!(px[0], 255);
325 assert!(px[3] == 200);
326 assert!(ToSrgb::from_icc(b"not a profile").is_none());
327 assert_eq!(expand(&[7, 9], 2), [7, 7, 7, 9]);
328 assert_eq!(
329 expand(&[1, 2, 3, 4, 5, 6, 7], 3),
330 [1, 2, 3, 255, 4, 5, 6, 255]
331 );
332 }
333}