| 1 | //! Colour to sRGB (ICC and CICP through moxcms), HDR tone-mapping and EXIF orientation. |
| 2 | |
| 3 | use image::DynamicImage; |
| 4 | use image::metadata::Orientation; |
| 5 | use 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). |
| 12 | pub struct ToSrgb(std::sync::Arc<moxcms::Transform8BitExecutor>); |
| 13 | |
| 14 | impl 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 | |
| 123 | fn 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. |
| 132 | fn 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. |
| 138 | pub 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. |
| 162 | pub 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. |
| 170 | pub 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. |
| 180 | pub 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. |
| 188 | pub 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. |
| 203 | pub 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). |
| 217 | pub 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)] |
| 243 | mod 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 | } |