#![allow(clippy::cast_possible_truncation)]
use alpha2_media::{ColorPrimaries, TransferFunction};
pub const SDR_WHITE_NITS: f32 = 100.0;
pub const DEFAULT_HDR_PEAK_NITS: u32 = 1_000;
const PQ_PEAK_NITS: f64 = 10_000.0;
const PQ_M1: f64 = 2_610.0 / 16_384.0;
const PQ_M2: f64 = 2_523.0 / 4_096.0 * 128.0;
const PQ_C1: f64 = 3_424.0 / 4_096.0;
const PQ_C2: f64 = 2_413.0 / 4_096.0 * 32.0;
const PQ_C3: f64 = 2_392.0 / 4_096.0 * 32.0;
const HLG_A: f64 = 0.178_832_77;
const HLG_B: f64 = 0.284_668_92;
const HLG_C: f64 = 0.559_910_73;
const BT2020_LUMA: [f64; 3] = [0.2627, 0.6780, 0.0593];
const HLG_MIN_LUMA: f64 = 1.0e-10;
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct HdrToneMap {
pub source_peak_nits: u32,
}
impl Default for HdrToneMap {
fn default() -> Self {
Self {
source_peak_nits: DEFAULT_HDR_PEAK_NITS,
}
}
}
#[must_use]
pub fn pq_eotf(code: f32) -> f32 {
let e = f64::from(code.clamp(0.0, 1.0)).powf(1.0 / PQ_M2);
let y = ((e - PQ_C1).max(0.0) / (PQ_C2 - PQ_C3 * e)).powf(1.0 / PQ_M1);
(y * PQ_PEAK_NITS) as f32
}
#[must_use]
pub fn pq_inverse_eotf(nits: f32) -> f32 {
let y = (f64::from(nits) / PQ_PEAK_NITS).clamp(0.0, 1.0).powf(PQ_M1);
((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y)).powf(PQ_M2) as f32
}
#[must_use]
pub fn hlg_inverse_oetf(code: f32) -> f32 {
let e = f64::from(code.clamp(0.0, 1.0));
let light = if e <= 0.5 {
e * e / 3.0
} else {
(((e - HLG_C) / HLG_A).exp() + HLG_B) / 12.0
};
light as f32
}
#[must_use]
pub fn hlg_ootf(scene: [f32; 3], peak_nits: f32) -> [f32; 3] {
let gamma = 1.2 + 0.42 * (f64::from(peak_nits) / 1_000.0).log10();
let luma: f64 = scene
.iter()
.zip(BT2020_LUMA)
.map(|(component, weight)| f64::from(*component) * weight)
.sum();
let gain = f64::from(peak_nits) * luma.max(HLG_MIN_LUMA).powf(gamma - 1.0);
scene.map(|component| (gain * f64::from(component)) as f32)
}
#[must_use]
pub fn bt2390_eetf(nits: f32, source_peak: f32, target_peak: f32) -> f32 {
let source = f64::from(pq_inverse_eotf(source_peak));
if source <= 0.0 {
return 0.0;
}
let value = f64::from(pq_inverse_eotf(nits.min(source_peak))) / source;
let max_lum = f64::from(pq_inverse_eotf(target_peak)) / source;
let knee = 1.5 * max_lum - 0.5;
let mapped = if value < knee || knee >= 1.0 {
value
} else {
let t = (value - knee) / (1.0 - knee);
let (t2, t3) = (t * t, t * t * t);
(2.0 * t3 - 3.0 * t2 + 1.0) * knee
+ (t3 - 2.0 * t2 + t) * (1.0 - knee)
+ (-2.0 * t3 + 3.0 * t2) * max_lum
};
pq_eotf((mapped * source) as f32)
}
#[must_use]
pub fn gamut_matrix(from: ColorPrimaries, to: ColorPrimaries) -> [[f32; 3]; 3] {
let from = rgb_to_xyz(chromaticities(from));
let to = invert(rgb_to_xyz(chromaticities(to)));
let product = multiply(to, from);
product.map(|row| row.map(|value| value as f32))
}
#[must_use]
pub fn hdr_to_working(
encoded: [f32; 3],
transfer: TransferFunction,
primaries: ColorPrimaries,
tone: HdrToneMap,
) -> [f32; 3] {
#[allow(clippy::cast_precision_loss)]
let peak = tone.source_peak_nits as f32;
let nits = match transfer {
TransferFunction::Hlg => hlg_ootf(encoded.map(hlg_inverse_oetf), peak),
_ => encoded.map(pq_eotf),
};
let gamut = gamut_matrix(primaries, ColorPrimaries::Bt709);
let converted = gamut.map(|row| {
row.iter()
.zip(nits)
.map(|(weight, component)| weight * component)
.sum::<f32>()
});
let largest = converted[0].max(converted[1]).max(converted[2]);
let scale = if largest > 0.0 {
bt2390_eetf(largest, peak, SDR_WHITE_NITS) / largest
} else {
0.0
};
converted.map(|component| (component * scale / SDR_WHITE_NITS).clamp(0.0, 1.0))
}
fn chromaticities(primaries: ColorPrimaries) -> [[f64; 2]; 4] {
const D65: [f64; 2] = [0.3127, 0.3290];
match primaries {
ColorPrimaries::Bt601 | ColorPrimaries::Bt709 => {
[[0.640, 0.330], [0.300, 0.600], [0.150, 0.060], D65]
}
ColorPrimaries::Bt2020 => [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046], D65],
}
}
fn rgb_to_xyz([red, green, blue, white]: [[f64; 2]; 4]) -> [[f64; 3]; 3] {
let xyz = |[x, y]: [f64; 2]| [x / y, 1.0, (1.0 - x - y) / y];
let columns = [xyz(red), xyz(green), xyz(blue)];
let primaries = [
[columns[0][0], columns[1][0], columns[2][0]],
[columns[0][1], columns[1][1], columns[2][1]],
[columns[0][2], columns[1][2], columns[2][2]],
];
let white = xyz(white);
let inverse = invert(primaries);
let scale = inverse.map(|row| row.iter().zip(white).map(|(a, b)| a * b).sum::<f64>());
primaries.map(|row| [row[0] * scale[0], row[1] * scale[1], row[2] * scale[2]])
}
fn multiply(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
let mut product = [[0.0; 3]; 3];
for (row, out) in product.iter_mut().enumerate() {
for (column, cell) in out.iter_mut().enumerate() {
*cell = (0..3).map(|k| a[row][k] * b[k][column]).sum();
}
}
product
}
fn invert(m: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
let cofactor =
|r0: usize, r1: usize, c0: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0];
let adjugate = [
[
cofactor(1, 2, 1, 2),
-cofactor(0, 2, 1, 2),
cofactor(0, 1, 1, 2),
],
[
-cofactor(1, 2, 0, 2),
cofactor(0, 2, 0, 2),
-cofactor(0, 1, 0, 2),
],
[
cofactor(1, 2, 0, 1),
-cofactor(0, 2, 0, 1),
cofactor(0, 1, 0, 1),
],
];
let determinant =
m[0][0] * adjugate[0][0] + m[0][1] * adjugate[1][0] + m[0][2] * adjugate[2][0];
adjugate.map(|row| row.map(|value| value / determinant))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pq_round_trips_and_hits_its_reference_points() {
assert!((pq_eotf(1.0) - 10_000.0).abs() < 0.5);
assert!(pq_eotf(0.0).abs() < 1.0e-6);
assert!((pq_inverse_eotf(100.0) - 0.5081).abs() < 1.0e-3);
for nits in [0.01_f32, 1.0, 100.0, 203.0, 1_000.0, 4_000.0] {
let back = pq_eotf(pq_inverse_eotf(nits));
assert!((back - nits).abs() / nits < 1.0e-4, "{nits} -> {back}");
}
}
#[test]
fn hlg_reference_white_is_203_nits_at_a_1000_nit_peak() {
let scene = hlg_inverse_oetf(0.75);
let [white, ..] = hlg_ootf([scene; 3], 1_000.0);
assert!((white - 203.0).abs() < 1.5, "{white}");
assert!(hlg_inverse_oetf(0.5).mul_add(3.0, -0.25).abs() < 1.0e-6);
}
#[test]
fn the_eetf_keeps_the_knee_reaches_the_target_and_never_rises_past_it() {
assert!((bt2390_eetf(10.0, 1_000.0, 100.0) - 10.0).abs() < 0.01);
assert!((bt2390_eetf(1_000.0, 1_000.0, 100.0) - 100.0).abs() < 0.05);
let mut previous = 0.0;
for step in 0_u16..=200 {
let nits = f32::from(step) * 5.0;
let mapped = bt2390_eetf(nits, 1_000.0, 100.0);
assert!(mapped >= previous - 1.0e-4 && mapped <= 100.0 + 1.0e-3);
previous = mapped;
}
assert!((bt2390_eetf(80.0, 100.0, 100.0) - 80.0).abs() < 0.01);
}
#[test]
fn gamut_matrices_convert_white_to_white_and_bt2020_red_out_of_gamut() {
let m = gamut_matrix(ColorPrimaries::Bt2020, ColorPrimaries::Bt709);
for row in m {
assert!((row.iter().sum::<f32>() - 1.0).abs() < 1.0e-4);
}
assert!((m[0][0] - 1.6605).abs() < 1.0e-3 && (m[2][2] - 1.1187).abs() < 1.0e-3);
let identity = gamut_matrix(ColorPrimaries::Bt709, ColorPrimaries::Bt709);
for (r, row) in identity.iter().enumerate() {
for (c, value) in row.iter().enumerate() {
assert!((value - f32::from(u8::from(r == c))).abs() < 1.0e-6);
}
}
}
#[test]
fn hlg_black_is_black_at_every_peak_the_model_accepts() {
for peak in [100_u32, 200, 333, 334, 335, 1_000, 10_000] {
#[allow(clippy::cast_precision_loss)]
let display = hlg_ootf([0.0; 3], peak as f32);
assert!(
display.iter().all(|c| *c == 0.0),
"{peak} nits: {display:?}"
);
let tone = HdrToneMap {
source_peak_nits: peak,
};
for code in [0.0, -0.05] {
let working = hdr_to_working(
[code; 3],
TransferFunction::Hlg,
ColorPrimaries::Bt2020,
tone,
);
assert!(
working.iter().all(|c| *c == 0.0),
"{peak} nits, code {code}: {working:?}"
);
}
let grey = hdr_to_working(
[0.1; 3],
TransferFunction::Hlg,
ColorPrimaries::Bt2020,
tone,
);
assert!(
grey.iter().all(|c| c.is_finite() && *c > 0.0),
"{peak} nits: {grey:?}"
);
}
}
#[test]
fn hdr_white_and_black_land_in_the_sdr_range_with_hue_kept() {
let tone = HdrToneMap::default();
let pq = |nits: f32| pq_inverse_eotf(nits);
let black = hdr_to_working([0.0; 3], TransferFunction::Pq, ColorPrimaries::Bt2020, tone);
assert!(black.iter().all(|component| *component == 0.0), "{black:?}");
let peak = hdr_to_working(
[pq(1_000.0); 3],
TransferFunction::Pq,
ColorPrimaries::Bt2020,
tone,
);
assert!(peak.iter().all(|c| (c - 1.0).abs() < 1.0e-3), "{peak:?}");
let grey = hdr_to_working(
[pq(10.0); 3],
TransferFunction::Pq,
ColorPrimaries::Bt2020,
tone,
);
assert!(grey.iter().all(|c| (c - 0.1).abs() < 2.0e-3), "{grey:?}");
let orange = hdr_to_working(
[pq(600.0), pq(300.0), pq(60.0)],
TransferFunction::Pq,
ColorPrimaries::Bt709,
tone,
);
assert!((orange[1] / orange[0] - 0.5).abs() < 1.0e-3, "{orange:?}");
assert!(orange[0] < 1.0 && orange[0] > 0.8);
let hlg = hdr_to_working(
[0.75; 3],
TransferFunction::Hlg,
ColorPrimaries::Bt2020,
tone,
);
assert!(hlg.iter().all(|c| *c > 0.8 && *c < 1.0), "{hlg:?}");
}
}