#![allow(clippy::excessive_precision)]
use zentone::curves::{
aces_ap1, agx_tonemap, filmic_narkowicz, hable_filmic, reinhard_extended, reinhard_simple,
};
use zentone::{AgxLook, LUMA_BT709, ToneMap, ToneMapCurve};
const TOL: f32 = 1e-6;
const SCALAR_SWEEP: [f32; 12] = [
0.0, 0.001, 0.01, 0.05, 0.1, 0.18, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0,
];
const RGB_TRIPLES: [[f32; 3]; 6] = [
[0.0, 0.0, 0.0],
[0.18, 0.18, 0.18],
[1.0, 0.5, 0.25],
[0.1, 0.9, 0.05],
[3.0, 1.5, 0.2],
[0.02, 0.001, 0.5],
];
fn assert_close(a: f32, b: f32, label: &str) {
let err = (a - b).abs();
assert!(err < TOL, "{label}: {a} vs {b}, err={err}");
}
fn hable_partial(x: f32) -> f32 {
const A: f32 = 0.15;
const B: f32 = 0.50;
const C: f32 = 0.10;
const D: f32 = 0.20;
const E: f32 = 0.02;
const F: f32 = 0.30;
((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F
}
fn hable_filmic_reference(v: f32) -> f32 {
const EXPOSURE_BIAS: f32 = 2.0;
const W: f32 = 11.2;
let w_scale = 1.0 / hable_partial(W);
(hable_partial(v * EXPOSURE_BIAS) * w_scale).min(1.0)
}
#[test]
fn hable_matches_hable_reference() {
for &x in &SCALAR_SWEEP {
let zt = hable_filmic(x);
let rf = hable_filmic_reference(x);
assert_close(zt, rf, &format!("hable({x})"));
}
}
fn narkowicz_reference(x: f32) -> f32 {
let a = 2.51_f32;
let b = 0.03_f32;
let c = 2.43_f32;
let d = 0.59_f32;
let e = 0.14_f32;
(x * (a * x + b) / (x * (c * x + d) + e)).clamp(0.0, 1.0)
}
#[test]
fn narkowicz_matches_reference() {
for &x in &SCALAR_SWEEP {
let zt = filmic_narkowicz(x);
let rf = narkowicz_reference(x);
assert_close(zt, rf, &format!("narkowicz({x})"));
}
}
fn aces_ap1_reference(rgb: [f32; 3]) -> [f32; 3] {
let a = 0.59719 * rgb[0] + 0.35458 * rgb[1] + 0.04823 * rgb[2];
let b = 0.07600 * rgb[0] + 0.90834 * rgb[1] + 0.01566 * rgb[2];
let c = 0.02840 * rgb[0] + 0.13383 * rgb[1] + 0.83777 * rgb[2];
let ra = a * (a + 0.0245786) - 0.000090537;
let rb = a * (a * 0.983729 + 0.4329510) + 0.238081;
let ga = b * (b + 0.0245786) - 0.000090537;
let gb = b * (b * 0.983729 + 0.4329510) + 0.238081;
let ba = c * (c + 0.0245786) - 0.000090537;
let bb = c * (c * 0.983729 + 0.4329510) + 0.238081;
let mr = ra / rb;
let mg = ga / gb;
let mb = ba / bb;
[
(1.60475 * mr - 0.53108 * mg - 0.07367 * mb).min(1.0),
(-0.10208 * mr + 1.10813 * mg - 0.00605 * mb).min(1.0),
(-0.00327 * mr - 0.07276 * mg + 1.07602 * mb).min(1.0),
]
}
#[test]
fn aces_ap1_matches_reference() {
for rgb in &RGB_TRIPLES {
let zt = aces_ap1(*rgb);
let rf = aces_ap1_reference(*rgb);
for i in 0..3 {
assert_close(zt[i], rf[i], &format!("aces_ap1({rgb:?})[{i}]"));
}
}
}
fn agx_contrast_reference(x: f32) -> f32 {
let x2 = x * x;
let x4 = x2 * x2;
let x6 = x4 * x2;
let w0 = 0.002857 - 0.1718 * x;
let w1 = 4.361 - 28.72 * x;
let w2 = 92.06 - 126.7 * x;
let w3 = 78.01 - 17.86 * x;
w0 + w1 * x2 + w2 * x4 + w3 * x6
}
#[test]
fn agx_contrast_polynomial_endpoints() {
let at_zero = agx_contrast_reference(0.0);
assert!(
(at_zero - 0.002857).abs() < 1e-5,
"contrast(0) should be ~0.003, got {at_zero}"
);
let at_one = agx_contrast_reference(1.0);
assert!(
(at_one - 0.982).abs() < 0.01,
"contrast(1) should be ~0.982, got {at_one}"
);
let mut last = agx_contrast_reference(0.05);
for i in 6..=100 {
let x = i as f32 / 100.0;
let y = agx_contrast_reference(x);
assert!(
y >= last - 1e-4,
"contrast not monotonic at x={x}: {y} < {last}"
);
last = y;
}
}
#[test]
fn agx_full_pipeline_all_looks() {
for &look in &[AgxLook::Default, AgxLook::Punchy, AgxLook::Golden] {
for rgb in &RGB_TRIPLES {
let out = agx_tonemap(*rgb, look);
for (i, c) in out.iter().enumerate() {
assert!(
c.is_finite() && (0.0..=1.0).contains(c),
"agx({rgb:?}, {look:?})[{i}] out of range: {c}"
);
}
}
}
}
#[test]
fn agx_curve_dispatch_matches_direct() {
for &look in &[AgxLook::Default, AgxLook::Punchy, AgxLook::Golden] {
let curve = ToneMapCurve::Agx(look);
for rgb in &RGB_TRIPLES {
let direct = agx_tonemap(*rgb, look);
let dispatch = curve.map_rgb(*rgb);
for i in 0..3 {
assert_close(
direct[i],
dispatch[i],
&format!("agx({look:?}) direct vs dispatch at {rgb:?}[{i}]"),
);
}
}
}
}
#[test]
fn reinhard_simple_is_x_over_1_plus_x() {
for &x in &SCALAR_SWEEP {
let expected = x / (1.0 + x);
assert_close(
reinhard_simple(x),
expected,
&format!("reinhard_simple({x})"),
);
}
}
#[test]
fn reinhard_extended_is_standard_formula() {
for &h in &[2.0_f32, 4.0, 8.0] {
for &x in &SCALAR_SWEEP {
let h2 = h * h;
let expected = x * (1.0 + x / h2) / (1.0 + x);
assert_close(
reinhard_extended(x, h),
expected,
&format!("reinhard_extended({x}, {h})"),
);
}
}
}
#[test]
fn all_luma_variants_use_bt709_consistently() {
let curves_709 = [
ToneMapCurve::ExtendedReinhard {
l_max: 4.0,
luma: LUMA_BT709,
},
ToneMapCurve::ReinhardJodie { luma: LUMA_BT709 },
ToneMapCurve::TunedReinhard {
content_max_nits: 4000.0,
display_max_nits: 250.0,
luma: LUMA_BT709,
},
];
let curves_2020 = [
ToneMapCurve::ExtendedReinhard {
l_max: 4.0,
luma: zentone::LUMA_BT2020,
},
ToneMapCurve::ReinhardJodie {
luma: zentone::LUMA_BT2020,
},
ToneMapCurve::TunedReinhard {
content_max_nits: 4000.0,
display_max_nits: 250.0,
luma: zentone::LUMA_BT2020,
},
];
let rgb = [0.1_f32, 0.9, 0.05];
for (c709, c2020) in curves_709.iter().zip(curves_2020.iter()) {
let out_709 = c709.map_rgb(rgb);
let out_2020 = c2020.map_rgb(rgb);
let mut any_diff = false;
for i in 0..3 {
if (out_709[i] - out_2020[i]).abs() > 1e-5 {
any_diff = true;
}
}
assert!(
any_diff,
"curve {c709:?} produced identical output for BT.709 vs BT.2020 luma"
);
}
}