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