Krolyxon
Commit 9f6e071

Branches

Tags

Refs →

remagesrcdecodecolor.rs

207 lines · 6.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) => Some(Self(t)),
61 Err(e) => {
62 log::debug!("colour transform unavailable: {e:?}");
63 None
64 }
65 }
66 }
67
68 /// Convert RGBA8 pixels in place; alpha is untouched.
69 pub fn apply(&self, pixels: &mut [u8]) {
70 let src = pixels.to_vec();
71 if let Err(e) = self.0.transform(&src, pixels) {
72 log::debug!("colour transform failed: {e:?}");
73 pixels.copy_from_slice(&src);
74 }
75 }
76}
77
78fn srgb_encode(v: f32) -> f32 {
79 if v <= 0.003_130_8 {
80 v * 12.92
81 } else {
82 1.055 * v.powf(1.0 / 2.4) - 0.055
83 }
84}
85
86/// Narkowicz's ACES fit: filmic highlights, unchanged blacks.
87fn aces(x: f32) -> f32 {
88 ((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)).clamp(0.0, 1.0)
89}
90
91/// Linear float RGBA (EXR, Radiance HDR, float TIFF) to sRGB RGBA8. Images whose values stay
92/// within 0..1 are only encoded; brighter ones are tone-mapped.
93pub fn tonemap(rgba: &[f32]) -> Vec<u8> {
94 let peak = rgba
95 .as_chunks::<4>()
96 .0
97 .iter()
98 .flat_map(|p| &p[..3])
99 .copied()
100 .filter(|v| v.is_finite())
101 .fold(0.0f32, f32::max);
102 let hdr = peak > 1.0;
103 let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
104 let mut out = Vec::with_capacity(rgba.len());
105 for p in rgba.as_chunks::<4>().0.iter() {
106 for &c in &p[..3] {
107 let c = if c.is_finite() { c.max(0.0) } else { 0.0 };
108 let c = if hdr { aces(c) } else { c };
109 out.push(to8(srgb_encode(c)));
110 }
111 out.push(to8(if p[3].is_finite() { p[3] } else { 1.0 }));
112 }
113 out
114}
115
116/// Whether the image holds float samples that need tone-mapping rather than a plain cast.
117pub fn is_float(img: &DynamicImage) -> bool {
118 matches!(
119 img,
120 DynamicImage::ImageRgb32F(_) | DynamicImage::ImageRgba32F(_)
121 )
122}
123
124/// Straight RGBA8 from any decoded image, tone-mapping float data.
125pub fn to_rgba8(img: DynamicImage) -> ([u32; 2], Vec<u8>) {
126 let size = [img.width(), img.height()];
127 if is_float(&img) {
128 let f = img.into_rgba32f();
129 (size, tonemap(f.as_raw()))
130 } else {
131 (size, img.into_rgba8().into_raw())
132 }
133}
134
135/// Rotate/flip RGBA8 pixels upright.
136pub fn orient(size: [u32; 2], pixels: Vec<u8>, o: Orientation) -> ([u32; 2], Vec<u8>) {
137 if o == Orientation::NoTransforms {
138 return (size, pixels);
139 }
140 let Some(buf) = image::RgbaImage::from_raw(size[0], size[1], pixels) else {
141 return (size, Vec::new());
142 };
143 let mut img = DynamicImage::ImageRgba8(buf);
144 img.apply_orientation(o);
145 let out = img.into_rgba8();
146 ([out.width(), out.height()], out.into_raw())
147}
148
149/// RGBA8 from interleaved 8-bit samples with 1–4 channels (grey, grey+alpha, RGB, RGBA).
150pub fn expand(samples: &[u8], channels: usize) -> Vec<u8> {
151 match channels {
152 4 => samples.to_vec(),
153 3 => samples
154 .as_chunks::<3>()
155 .0
156 .iter()
157 .flat_map(|p| [p[0], p[1], p[2], 255])
158 .collect(),
159 2 => samples
160 .as_chunks::<2>()
161 .0
162 .iter()
163 .flat_map(|p| [p[0], p[0], p[0], p[1]])
164 .collect(),
165 _ => samples.iter().flat_map(|&g| [g, g, g, 255]).collect(),
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn tonemapping() {
175 // In-range linear data is only sRGB-encoded.
176 let px = tonemap(&[0.0, 0.2158, 1.0, 0.5]);
177 assert_eq!(px, [0, 128, 255, 128]);
178 // HDR highlights roll off instead of clipping, and stay ordered.
179 let px = tonemap(&[1.0, 4.0, 40.0, 1.0, f32::NAN, -1.0, 0.0, f32::INFINITY]);
180 assert!(px[0] < px[1] && px[1] <= px[2] && px[2] == 255, "{px:?}");
181 assert_eq!(&px[4..8], [0, 0, 0, 255]);
182 }
183
184 #[test]
185 fn orientation_turns_pixels() {
186 // 2×1: red, green. Rotate 90° clockwise → 1×2: red on top.
187 let px = vec![255, 0, 0, 255, 0, 255, 0, 255];
188 let (size, out) = orient([2, 1], px, Orientation::Rotate90);
189 assert_eq!(size, [1, 2]);
190 assert_eq!(&out[..4], [255, 0, 0, 255]);
191 let (size, out) = orient([2, 1], out.clone(), Orientation::NoTransforms);
192 assert_eq!((size, out.len()), ([2, 1], 8));
193 }
194
195 #[test]
196 fn cicp_and_icc() {
197 assert!(ToSrgb::from_cicp(1, 13).is_none(), "BT.709 is already sRGB");
198 // Display P3 primaries with the sRGB curve: pure P3 red is outside sRGB.
199 let t = ToSrgb::from_cicp(12, 13).expect("P3 transform");
200 let mut px = [255, 0, 0, 200];
201 t.apply(&mut px);
202 assert_eq!(px[0], 255);
203 assert!(px[3] == 200);
204 assert!(ToSrgb::from_icc(b"not a profile").is_none());
205 assert_eq!(expand(&[7, 9], 2), [7, 7, 7, 9]);
206 }
207}