1#![allow(clippy::cast_possible_truncation)]
17
18use alpha2_media::{ColorPrimaries, TransferFunction};
19
20pub const SDR_WHITE_NITS: f32 = 100.0;
22pub const DEFAULT_HDR_PEAK_NITS: u32 = 1_000;
24const PQ_PEAK_NITS: f64 = 10_000.0;
26
27const PQ_M1: f64 = 2_610.0 / 16_384.0;
29const PQ_M2: f64 = 2_523.0 / 4_096.0 * 128.0;
30const PQ_C1: f64 = 3_424.0 / 4_096.0;
31const PQ_C2: f64 = 2_413.0 / 4_096.0 * 32.0;
32const PQ_C3: f64 = 2_392.0 / 4_096.0 * 32.0;
33
34const HLG_A: f64 = 0.178_832_77;
36const HLG_B: f64 = 0.284_668_92;
37const HLG_C: f64 = 0.559_910_73;
38const BT2020_LUMA: [f64; 3] = [0.2627, 0.6780, 0.0593];
40const HLG_MIN_LUMA: f64 = 1.0e-10;
42
43#[derive(Copy, Clone, Debug, Eq, PartialEq)]
45pub struct HdrToneMap {
46 pub source_peak_nits: u32,
50}
51
52impl Default for HdrToneMap {
53 fn default() -> Self {
54 Self {
55 source_peak_nits: DEFAULT_HDR_PEAK_NITS,
56 }
57 }
58}
59
60#[must_use]
62pub fn pq_eotf(code: f32) -> f32 {
63 let e = f64::from(code.clamp(0.0, 1.0)).powf(1.0 / PQ_M2);
64 let y = ((e - PQ_C1).max(0.0) / (PQ_C2 - PQ_C3 * e)).powf(1.0 / PQ_M1);
65 (y * PQ_PEAK_NITS) as f32
66}
67
68#[must_use]
70pub fn pq_inverse_eotf(nits: f32) -> f32 {
71 let y = (f64::from(nits) / PQ_PEAK_NITS).clamp(0.0, 1.0).powf(PQ_M1);
72 ((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y)).powf(PQ_M2) as f32
73}
74
75#[must_use]
77pub fn hlg_inverse_oetf(code: f32) -> f32 {
78 let e = f64::from(code.clamp(0.0, 1.0));
79 let light = if e <= 0.5 {
80 e * e / 3.0
81 } else {
82 (((e - HLG_C) / HLG_A).exp() + HLG_B) / 12.0
83 };
84 light as f32
85}
86
87#[must_use]
90pub fn hlg_ootf(scene: [f32; 3], peak_nits: f32) -> [f32; 3] {
91 let gamma = 1.2 + 0.42 * (f64::from(peak_nits) / 1_000.0).log10();
92 let luma: f64 = scene
93 .iter()
94 .zip(BT2020_LUMA)
95 .map(|(component, weight)| f64::from(*component) * weight)
96 .sum();
97 let gain = f64::from(peak_nits) * luma.max(HLG_MIN_LUMA).powf(gamma - 1.0);
100 scene.map(|component| (gain * f64::from(component)) as f32)
101}
102
103#[must_use]
109pub fn bt2390_eetf(nits: f32, source_peak: f32, target_peak: f32) -> f32 {
110 let source = f64::from(pq_inverse_eotf(source_peak));
111 if source <= 0.0 {
112 return 0.0;
113 }
114 let value = f64::from(pq_inverse_eotf(nits.min(source_peak))) / source;
115 let max_lum = f64::from(pq_inverse_eotf(target_peak)) / source;
116 let knee = 1.5 * max_lum - 0.5;
117 let mapped = if value < knee || knee >= 1.0 {
118 value
119 } else {
120 let t = (value - knee) / (1.0 - knee);
121 let (t2, t3) = (t * t, t * t * t);
122 (2.0 * t3 - 3.0 * t2 + 1.0) * knee
123 + (t3 - 2.0 * t2 + t) * (1.0 - knee)
124 + (-2.0 * t3 + 3.0 * t2) * max_lum
125 };
126 pq_eotf((mapped * source) as f32)
127}
128
129#[must_use]
133pub fn gamut_matrix(from: ColorPrimaries, to: ColorPrimaries) -> [[f32; 3]; 3] {
134 let from = rgb_to_xyz(chromaticities(from));
135 let to = invert(rgb_to_xyz(chromaticities(to)));
136 let product = multiply(to, from);
137 product.map(|row| row.map(|value| value as f32))
138}
139
140#[must_use]
143pub fn hdr_to_working(
144 encoded: [f32; 3],
145 transfer: TransferFunction,
146 primaries: ColorPrimaries,
147 tone: HdrToneMap,
148) -> [f32; 3] {
149 #[allow(clippy::cast_precision_loss)]
150 let peak = tone.source_peak_nits as f32;
151 let nits = match transfer {
152 TransferFunction::Hlg => hlg_ootf(encoded.map(hlg_inverse_oetf), peak),
153 _ => encoded.map(pq_eotf),
154 };
155 let gamut = gamut_matrix(primaries, ColorPrimaries::Bt709);
156 let converted = gamut.map(|row| {
157 row.iter()
158 .zip(nits)
159 .map(|(weight, component)| weight * component)
160 .sum::<f32>()
161 });
162 let largest = converted[0].max(converted[1]).max(converted[2]);
163 let scale = if largest > 0.0 {
164 bt2390_eetf(largest, peak, SDR_WHITE_NITS) / largest
165 } else {
166 0.0
167 };
168 converted.map(|component| (component * scale / SDR_WHITE_NITS).clamp(0.0, 1.0))
169}
170
171fn chromaticities(primaries: ColorPrimaries) -> [[f64; 2]; 4] {
173 const D65: [f64; 2] = [0.3127, 0.3290];
174 match primaries {
175 ColorPrimaries::Bt601 | ColorPrimaries::Bt709 => {
176 [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060], D65]
177 }
178 ColorPrimaries::Bt2020 => [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046], D65],
179 }
180}
181
182fn rgb_to_xyz([red, green, blue, white]: [[f64; 2]; 4]) -> [[f64; 3]; 3] {
184 let xyz = |[x, y]: [f64; 2]| [x / y, 1.0, (1.0 - x - y) / y];
185 let columns = [xyz(red), xyz(green), xyz(blue)];
186 let primaries = [
187 [columns[0][0], columns[1][0], columns[2][0]],
188 [columns[0][1], columns[1][1], columns[2][1]],
189 [columns[0][2], columns[1][2], columns[2][2]],
190 ];
191 let white = xyz(white);
192 let inverse = invert(primaries);
193 let scale = inverse.map(|row| row.iter().zip(white).map(|(a, b)| a * b).sum::<f64>());
194 primaries.map(|row| [row[0] * scale[0], row[1] * scale[1], row[2] * scale[2]])
195}
196
197fn multiply(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
198 let mut product = [[0.0; 3]; 3];
199 for (row, out) in product.iter_mut().enumerate() {
200 for (column, cell) in out.iter_mut().enumerate() {
201 *cell = (0..3).map(|k| a[row][k] * b[k][column]).sum();
202 }
203 }
204 product
205}
206
207fn invert(m: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
208 let cofactor =
209 |r0: usize, r1: usize, c0: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0];
210 let adjugate = [
211 [
212 cofactor(1, 2, 1, 2),
213 -cofactor(0, 2, 1, 2),
214 cofactor(0, 1, 1, 2),
215 ],
216 [
217 -cofactor(1, 2, 0, 2),
218 cofactor(0, 2, 0, 2),
219 -cofactor(0, 1, 0, 2),
220 ],
221 [
222 cofactor(1, 2, 0, 1),
223 -cofactor(0, 2, 0, 1),
224 cofactor(0, 1, 0, 1),
225 ],
226 ];
227 let determinant =
228 m[0][0] * adjugate[0][0] + m[0][1] * adjugate[1][0] + m[0][2] * adjugate[2][0];
229 adjugate.map(|row| row.map(|value| value / determinant))
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn pq_round_trips_and_hits_its_reference_points() {
238 assert!((pq_eotf(1.0) - 10_000.0).abs() < 0.5);
239 assert!(pq_eotf(0.0).abs() < 1.0e-6);
240 assert!((pq_inverse_eotf(100.0) - 0.5081).abs() < 1.0e-3);
242 for nits in [0.01_f32, 1.0, 100.0, 203.0, 1_000.0, 4_000.0] {
243 let back = pq_eotf(pq_inverse_eotf(nits));
244 assert!((back - nits).abs() / nits < 1.0e-4, "{nits} -> {back}");
245 }
246 }
247
248 #[test]
249 fn hlg_reference_white_is_203_nits_at_a_1000_nit_peak() {
250 let scene = hlg_inverse_oetf(0.75);
252 let [white, ..] = hlg_ootf([scene; 3], 1_000.0);
253 assert!((white - 203.0).abs() < 1.5, "{white}");
254 assert!(hlg_inverse_oetf(0.5).mul_add(3.0, -0.25).abs() < 1.0e-6);
255 }
256
257 #[test]
258 fn the_eetf_keeps_the_knee_reaches_the_target_and_never_rises_past_it() {
259 assert!((bt2390_eetf(10.0, 1_000.0, 100.0) - 10.0).abs() < 0.01);
260 assert!((bt2390_eetf(1_000.0, 1_000.0, 100.0) - 100.0).abs() < 0.05);
262 let mut previous = 0.0;
263 for step in 0_u16..=200 {
264 let nits = f32::from(step) * 5.0;
265 let mapped = bt2390_eetf(nits, 1_000.0, 100.0);
266 assert!(mapped >= previous - 1.0e-4 && mapped <= 100.0 + 1.0e-3);
267 previous = mapped;
268 }
269 assert!((bt2390_eetf(80.0, 100.0, 100.0) - 80.0).abs() < 0.01);
271 }
272
273 #[test]
274 fn gamut_matrices_convert_white_to_white_and_bt2020_red_out_of_gamut() {
275 let m = gamut_matrix(ColorPrimaries::Bt2020, ColorPrimaries::Bt709);
276 for row in m {
277 assert!((row.iter().sum::<f32>() - 1.0).abs() < 1.0e-4);
278 }
279 assert!((m[0][0] - 1.6605).abs() < 1.0e-3 && (m[2][2] - 1.1187).abs() < 1.0e-3);
281 let identity = gamut_matrix(ColorPrimaries::Bt709, ColorPrimaries::Bt709);
282 for (r, row) in identity.iter().enumerate() {
283 for (c, value) in row.iter().enumerate() {
284 assert!((value - f32::from(u8::from(r == c))).abs() < 1.0e-6);
285 }
286 }
287 }
288
289 #[test]
290 fn hlg_black_is_black_at_every_peak_the_model_accepts() {
291 for peak in [100_u32, 200, 333, 334, 335, 1_000, 10_000] {
293 #[allow(clippy::cast_precision_loss)]
294 let display = hlg_ootf([0.0; 3], peak as f32);
295 assert!(
296 display.iter().all(|c| *c == 0.0),
297 "{peak} nits: {display:?}"
298 );
299 let tone = HdrToneMap {
300 source_peak_nits: peak,
301 };
302 for code in [0.0, -0.05] {
304 let working = hdr_to_working(
305 [code; 3],
306 TransferFunction::Hlg,
307 ColorPrimaries::Bt2020,
308 tone,
309 );
310 assert!(
311 working.iter().all(|c| *c == 0.0),
312 "{peak} nits, code {code}: {working:?}"
313 );
314 }
315 let grey = hdr_to_working(
317 [0.1; 3],
318 TransferFunction::Hlg,
319 ColorPrimaries::Bt2020,
320 tone,
321 );
322 assert!(
323 grey.iter().all(|c| c.is_finite() && *c > 0.0),
324 "{peak} nits: {grey:?}"
325 );
326 }
327 }
328
329 #[test]
330 fn hdr_white_and_black_land_in_the_sdr_range_with_hue_kept() {
331 let tone = HdrToneMap::default();
332 let pq = |nits: f32| pq_inverse_eotf(nits);
333 let black = hdr_to_working([0.0; 3], TransferFunction::Pq, ColorPrimaries::Bt2020, tone);
334 assert!(black.iter().all(|component| *component == 0.0), "{black:?}");
335 let peak = hdr_to_working(
337 [pq(1_000.0); 3],
338 TransferFunction::Pq,
339 ColorPrimaries::Bt2020,
340 tone,
341 );
342 assert!(peak.iter().all(|c| (c - 1.0).abs() < 1.0e-3), "{peak:?}");
343 let grey = hdr_to_working(
345 [pq(10.0); 3],
346 TransferFunction::Pq,
347 ColorPrimaries::Bt2020,
348 tone,
349 );
350 assert!(grey.iter().all(|c| (c - 0.1).abs() < 2.0e-3), "{grey:?}");
351 let orange = hdr_to_working(
353 [pq(600.0), pq(300.0), pq(60.0)],
354 TransferFunction::Pq,
355 ColorPrimaries::Bt709,
356 tone,
357 );
358 assert!((orange[1] / orange[0] - 0.5).abs() < 1.0e-3, "{orange:?}");
359 assert!(orange[0] < 1.0 && orange[0] > 0.8);
360 let hlg = hdr_to_working(
362 [0.75; 3],
363 TransferFunction::Hlg,
364 ColorPrimaries::Bt2020,
365 tone,
366 );
367 assert!(hlg.iter().all(|c| *c > 0.8 && *c < 1.0), "{hlg:?}");
368 }
369}