use crate::math::{exp2f, log2f};
use crate::{
Bt2408Tonemapper, Bt2446A, Bt2446B, Bt2446C, CompiledFilmicSpline, ToneMap, ToneMapCurve,
};
use alloc::vec;
use linear_srgb::tf::{linear_to_pq, pq_to_linear};
pub trait LumaToneMap {
fn map_luma(&self, y_hdr: f32) -> f32;
}
pub struct LumaFn<F: Fn(f32) -> f32>(pub F);
impl<F: Fn(f32) -> f32> LumaToneMap for LumaFn<F> {
#[inline]
fn map_luma(&self, y: f32) -> f32 {
(self.0)(y)
}
}
impl<T: LumaToneMap + ?Sized> LumaToneMap for &T {
#[inline]
fn map_luma(&self, y: f32) -> f32 {
(**self).map_luma(y)
}
}
impl<T: LumaToneMap + ?Sized> LumaToneMap for alloc::boxed::Box<T> {
#[inline]
fn map_luma(&self, y: f32) -> f32 {
(**self).map_luma(y)
}
}
pub struct Bt2408Yrgb(Bt2408Tonemapper);
impl Bt2408Yrgb {
pub fn new(content_max_nits: f32, display_max_nits: f32) -> Self {
Self(Bt2408Tonemapper::new(content_max_nits, display_max_nits))
}
pub fn with_luma(content_max_nits: f32, display_max_nits: f32, luma: [f32; 3]) -> Self {
Self(Bt2408Tonemapper::with_luma(
content_max_nits,
display_max_nits,
luma,
))
}
}
impl LumaToneMap for Bt2408Yrgb {
#[inline]
fn map_luma(&self, y: f32) -> f32 {
self.0.map_rgb([y, y, y])[0]
}
}
impl LumaToneMap for Bt2446C {
#[inline]
fn map_luma(&self, y: f32) -> f32 {
self.map_rgb([y, y, y])[0]
}
}
impl LumaToneMap for Bt2446A {
#[inline]
fn map_luma(&self, y: f32) -> f32 {
self.map_rgb([y, y, y])[0]
}
}
impl LumaToneMap for Bt2446B {
#[inline]
fn map_luma(&self, y: f32) -> f32 {
self.map_rgb([y, y, y])[0]
}
}
pub struct ExtendedReinhardLuma {
curve: ToneMapCurve,
}
impl ExtendedReinhardLuma {
pub fn new(l_max: f32, luma: [f32; 3]) -> Self {
Self {
curve: ToneMapCurve::ExtendedReinhard { l_max, luma },
}
}
}
impl LumaToneMap for ExtendedReinhardLuma {
#[inline]
fn map_luma(&self, y: f32) -> f32 {
self.curve.map_rgb([y, y, y])[0]
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct HableFilmic;
impl HableFilmic {
pub const fn new() -> Self {
Self
}
}
#[inline(always)]
const 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
}
impl LumaToneMap for HableFilmic {
#[inline]
fn map_luma(&self, y: f32) -> f32 {
const EXPOSURE_BIAS: f32 = 2.0;
const W: f32 = 11.2;
const W_SCALE: f32 = 1.0 / hable_partial(W);
let y = y.max(0.0);
(hable_partial(y * EXPOSURE_BIAS) * W_SCALE).min(1.0)
}
}
impl LumaToneMap for CompiledFilmicSpline {
#[inline]
fn map_luma(&self, y: f32) -> f32 {
self.map_rgb([y, y, y])[0]
}
}
#[derive(Debug, Clone, Copy)]
pub struct SplitConfig {
pub luma_weights: [f32; 3],
pub base_offset: f32,
pub alternate_offset: f32,
pub min_log2: f32,
pub max_log2: f32,
pub pre_desaturate: f32,
pub hlg_ootf_mode: crate::pipeline::HlgOotfMode,
}
impl Default for SplitConfig {
fn default() -> Self {
Self {
luma_weights: crate::LUMA_BT2020,
base_offset: 1.0 / 64.0,
alternate_offset: 1.0 / 64.0,
min_log2: -4.0,
max_log2: 6.0,
pre_desaturate: 0.0,
hlg_ootf_mode: crate::pipeline::HlgOotfMode::Exact,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct SplitStats {
pub observed_min_log2: f32,
pub observed_max_log2: f32,
pub clipped_sdr_pixels: u32,
}
impl Default for SplitStats {
fn default() -> Self {
Self {
observed_min_log2: f32::INFINITY,
observed_max_log2: f32::NEG_INFINITY,
clipped_sdr_pixels: 0,
}
}
}
pub struct LumaGainMapSplitter<T: LumaToneMap> {
curve: T,
cfg: SplitConfig,
}
impl<T: LumaToneMap> LumaGainMapSplitter<T> {
pub fn new(curve: T, cfg: SplitConfig) -> Self {
Self { curve, cfg }
}
pub fn config(&self) -> &SplitConfig {
&self.cfg
}
pub fn curve(&self) -> &T {
&self.curve
}
pub fn split_row(
&self,
hdr: &[f32],
sdr_out: &mut [f32],
gain_out: &mut [f32],
channels: u8,
stats: &mut SplitStats,
) {
match channels {
3 => self.split_cn::<3>(hdr, sdr_out, gain_out, stats),
4 => self.split_cn::<4>(hdr, sdr_out, gain_out, stats),
_ => panic!("channels must be 3 or 4, got {channels}"),
}
}
pub fn apply_row(&self, sdr: &[f32], gain: &[f32], hdr_out: &mut [f32], channels: u8) {
match channels {
3 => self.apply_cn::<3>(sdr, gain, hdr_out),
4 => self.apply_cn::<4>(sdr, gain, hdr_out),
_ => panic!("channels must be 3 or 4, got {channels}"),
}
}
#[inline]
fn split_cn<const CN: usize>(
&self,
hdr: &[f32],
sdr: &mut [f32],
gain: &mut [f32],
st: &mut SplitStats,
) {
debug_assert!(CN == 3 || CN == 4);
assert_eq!(hdr.len(), sdr.len());
assert_eq!(hdr.len() / CN, gain.len());
let [wr, wg, wb] = self.cfg.luma_weights;
let (b, a) = (self.cfg.base_offset, self.cfg.alternate_offset);
let (lo, hi) = (self.cfg.min_log2, self.cfg.max_log2);
let alpha = self.cfg.pre_desaturate;
let has_ct = alpha > 0.0;
for ((h, s), gp) in hdr
.chunks_exact(CN)
.zip(sdr.chunks_exact_mut(CN))
.zip(gain.iter_mut())
{
let r = h[0].max(0.0);
let gc = h[1].max(0.0);
let bl = h[2].max(0.0);
let (cr, cg, cb) = if has_ct {
let d = 1.0 - 2.0 * alpha;
(
d * r + alpha * gc + alpha * bl,
alpha * r + d * gc + alpha * bl,
alpha * r + alpha * gc + d * bl,
)
} else {
(r, gc, bl)
};
let y_hdr = wr * cr + wg * cg + wb * cb;
let y_sdr = self.curve.map_luma(y_hdr).clamp(0.0, 1.0);
let raw_log2 = log2f((y_hdr + a) / (y_sdr + b));
if raw_log2 < st.observed_min_log2 {
st.observed_min_log2 = raw_log2;
}
if raw_log2 > st.observed_max_log2 {
st.observed_max_log2 = raw_log2;
}
let g_log2 = raw_log2.clamp(lo, hi);
let m = exp2f(g_log2);
let d0 = (cr + a) / m - b;
let d1 = (cg + a) / m - b;
let d2 = (cb + a) / m - b;
let (s0, s1, s2) = if has_ct {
let inv_a = -alpha / (1.0 - 3.0 * alpha);
let id = 1.0 - 2.0 * inv_a;
(
id * d0 + inv_a * d1 + inv_a * d2,
inv_a * d0 + id * d1 + inv_a * d2,
inv_a * d0 + inv_a * d1 + id * d2,
)
} else {
(d0, d1, d2)
};
const CLIP_EPS: f32 = 1.0e-4;
let clipped = s0 < -CLIP_EPS
|| s1 < -CLIP_EPS
|| s2 < -CLIP_EPS
|| s0 > 1.0 + CLIP_EPS
|| s1 > 1.0 + CLIP_EPS
|| s2 > 1.0 + CLIP_EPS;
if clipped {
st.clipped_sdr_pixels = st.clipped_sdr_pixels.saturating_add(1);
}
s[0] = s0.clamp(0.0, 1.0);
s[1] = s1.clamp(0.0, 1.0);
s[2] = s2.clamp(0.0, 1.0);
if CN == 4 {
s[3] = h[3];
}
*gp = g_log2;
}
}
#[inline]
fn apply_cn<const CN: usize>(&self, sdr: &[f32], gain: &[f32], hdr: &mut [f32]) {
debug_assert!(CN == 3 || CN == 4);
assert_eq!(sdr.len(), hdr.len());
assert_eq!(sdr.len() / CN, gain.len());
let (b, a) = (self.cfg.base_offset, self.cfg.alternate_offset);
for ((s, &g), h) in sdr
.chunks_exact(CN)
.zip(gain.iter())
.zip(hdr.chunks_exact_mut(CN))
{
let m = exp2f(g);
h[0] = ((s[0] + b) * m - a).max(0.0);
h[1] = ((s[1] + b) * m - a).max(0.0);
h[2] = ((s[2] + b) * m - a).max(0.0);
if CN == 4 {
h[3] = s[3];
}
}
}
pub fn split_pq_row(
&self,
pq: &[f32],
sdr_linear: &mut [f32],
gain: &mut [f32],
channels: u8,
content_peak_nits: f32,
stats: &mut SplitStats,
) {
let mut linear = vec![0.0_f32; pq.len()];
linear.copy_from_slice(pq);
pq_to_normalized_linear_row(&mut linear, channels, content_peak_nits);
self.split_row(&linear, sdr_linear, gain, channels, stats);
}
#[allow(clippy::too_many_arguments)]
pub fn split_hlg_row(
&self,
hlg: &[f32],
sdr_linear: &mut [f32],
gain: &mut [f32],
channels: u8,
display_peak_nits: f32,
content_peak_nits: f32,
stats: &mut SplitStats,
) {
let mut linear = vec![0.0_f32; hlg.len()];
linear.copy_from_slice(hlg);
hlg_to_normalized_linear_row_with_mode(
&mut linear,
channels,
display_peak_nits,
content_peak_nits,
self.cfg.hlg_ootf_mode,
);
self.split_row(&linear, sdr_linear, gain, channels, stats);
}
pub fn apply_hlg_row(
&self,
sdr_linear: &[f32],
gain: &[f32],
hlg_out: &mut [f32],
channels: u8,
display_peak_nits: f32,
content_peak_nits: f32,
) {
self.apply_row(sdr_linear, gain, hlg_out, channels);
normalized_linear_to_hlg_row_with_mode(
hlg_out,
channels,
display_peak_nits,
content_peak_nits,
self.cfg.hlg_ootf_mode,
);
}
pub fn apply_pq_row(
&self,
sdr_linear: &[f32],
gain: &[f32],
pq_out: &mut [f32],
channels: u8,
content_peak_nits: f32,
) {
self.apply_row(sdr_linear, gain, pq_out, channels);
normalized_linear_to_pq_row(pq_out, channels, content_peak_nits);
}
}
pub fn pq_to_normalized_linear_row(in_out: &mut [f32], channels: u8, content_peak_nits: f32) {
assert!(channels == 3 || channels == 4, "channels must be 3 or 4");
assert!(
content_peak_nits > 0.0,
"content_peak_nits must be positive"
);
let scale = 10000.0 / content_peak_nits;
let ch = channels as usize;
for px in in_out.chunks_exact_mut(ch) {
px[0] = pq_to_linear(px[0]) * scale;
px[1] = pq_to_linear(px[1]) * scale;
px[2] = pq_to_linear(px[2]) * scale;
}
}
pub fn normalized_linear_to_pq_row(in_out: &mut [f32], channels: u8, content_peak_nits: f32) {
assert!(channels == 3 || channels == 4, "channels must be 3 or 4");
assert!(
content_peak_nits > 0.0,
"content_peak_nits must be positive"
);
let inv_scale = content_peak_nits / 10000.0;
let ch = channels as usize;
for px in in_out.chunks_exact_mut(ch) {
px[0] = linear_to_pq(px[0] * inv_scale);
px[1] = linear_to_pq(px[1] * inv_scale);
px[2] = linear_to_pq(px[2] * inv_scale);
}
}
pub fn hlg_to_normalized_linear_row(
in_out: &mut [f32],
channels: u8,
display_peak_nits: f32,
content_peak_nits: f32,
) {
hlg_to_normalized_linear_row_with_mode(
in_out,
channels,
display_peak_nits,
content_peak_nits,
crate::pipeline::HlgOotfMode::Exact,
);
}
pub fn hlg_to_normalized_linear_row_with_mode(
in_out: &mut [f32],
channels: u8,
display_peak_nits: f32,
content_peak_nits: f32,
mode: crate::pipeline::HlgOotfMode,
) {
assert!(channels == 3 || channels == 4, "channels must be 3 or 4");
assert!(
content_peak_nits > 0.0,
"content_peak_nits must be positive"
);
let gamma = crate::hlg::hlg_system_gamma(display_peak_nits);
let scale = display_peak_nits / content_peak_nits;
let ch = channels as usize;
for px in in_out.chunks_exact_mut(ch) {
let scene = [
linear_srgb::tf::hlg_to_linear(px[0]),
linear_srgb::tf::hlg_to_linear(px[1]),
linear_srgb::tf::hlg_to_linear(px[2]),
];
let display = match mode {
crate::pipeline::HlgOotfMode::Exact => crate::hlg::hlg_ootf(scene, gamma),
crate::pipeline::HlgOotfMode::LibultrahdrCompat => {
crate::hlg::hlg_ootf_approx(scene, gamma)
}
};
px[0] = display[0] * scale;
px[1] = display[1] * scale;
px[2] = display[2] * scale;
}
}
pub fn normalized_linear_to_hlg_row(
in_out: &mut [f32],
channels: u8,
display_peak_nits: f32,
content_peak_nits: f32,
) {
normalized_linear_to_hlg_row_with_mode(
in_out,
channels,
display_peak_nits,
content_peak_nits,
crate::pipeline::HlgOotfMode::Exact,
);
}
pub fn normalized_linear_to_hlg_row_with_mode(
in_out: &mut [f32],
channels: u8,
display_peak_nits: f32,
content_peak_nits: f32,
mode: crate::pipeline::HlgOotfMode,
) {
assert!(channels == 3 || channels == 4, "channels must be 3 or 4");
assert!(
content_peak_nits > 0.0,
"content_peak_nits must be positive"
);
let gamma = crate::hlg::hlg_system_gamma(display_peak_nits);
let inv_scale = content_peak_nits / display_peak_nits;
let ch = channels as usize;
for px in in_out.chunks_exact_mut(ch) {
let display = [px[0] * inv_scale, px[1] * inv_scale, px[2] * inv_scale];
let scene = match mode {
crate::pipeline::HlgOotfMode::Exact => crate::hlg::hlg_inverse_ootf(display, gamma),
crate::pipeline::HlgOotfMode::LibultrahdrCompat => {
crate::hlg::hlg_inverse_ootf_approx(display, gamma)
}
};
px[0] = linear_srgb::tf::linear_to_hlg(scene[0]);
px[1] = linear_srgb::tf::linear_to_hlg(scene[1]);
px[2] = linear_srgb::tf::linear_to_hlg(scene[2]);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FilmicSplineConfig, LUMA_BT709, LUMA_BT2020};
use alloc::vec;
use alloc::vec::Vec;
fn assert_luma_curve_well_behaved<T: LumaToneMap>(curve: &T, max_input: f32, name: &str) {
let zero = curve.map_luma(0.0);
assert!(zero.abs() < 2e-3, "{name}: T(0) = {zero}, expected near 0");
let mut prev = curve.map_luma(0.0);
let steps = 256;
for i in 1..=steps {
let y = (i as f32 / steps as f32) * max_input;
let s = curve.map_luma(y);
assert!(s.is_finite(), "{name}: T({y}) = {s} not finite");
assert!(
s >= prev - 1.0e-5,
"{name}: not monotonic at y={y}: {prev} -> {s}"
);
prev = s;
}
}
#[test]
fn bt2446c_well_behaved() {
let c = Bt2446C::new(1000.0, 100.0);
assert_luma_curve_well_behaved(&c, 10.0, "Bt2446C");
}
#[test]
fn bt2446a_well_behaved() {
let c = Bt2446A::new(1000.0, 100.0);
assert_luma_curve_well_behaved(&c, 10.0, "Bt2446A");
}
#[test]
fn bt2446b_well_behaved() {
let c = Bt2446B::new(1000.0, 100.0);
assert_luma_curve_well_behaved(&c, 10.0, "Bt2446B");
}
#[test]
fn bt2408_yrgb_well_behaved() {
let c = Bt2408Yrgb::new(4000.0, 1000.0);
assert_luma_curve_well_behaved(&c, 10.0, "Bt2408Yrgb");
}
#[test]
fn extended_reinhard_well_behaved() {
let c = ExtendedReinhardLuma::new(4.0, LUMA_BT709);
assert_luma_curve_well_behaved(&c, 10.0, "ExtendedReinhardLuma");
}
#[test]
fn filmic_spline_well_behaved() {
let c = CompiledFilmicSpline::new(&FilmicSplineConfig::default());
assert_luma_curve_well_behaved(&c, 10.0, "CompiledFilmicSpline");
}
#[test]
fn hable_filmic_well_behaved() {
let c = HableFilmic::new();
assert_luma_curve_well_behaved(&c, 10.0, "HableFilmic");
}
#[test]
fn hable_filmic_clamps_high_input() {
let c = HableFilmic::new();
let s = c.map_luma(100.0);
assert!((0.0..=1.0).contains(&s), "HableFilmic out of range: {s}");
assert!(s > 0.95, "HableFilmic expected near 1.0 at high input: {s}");
}
#[test]
fn round_trip_hable_filmic_grayscale_exact() {
let split = LumaGainMapSplitter::new(
HableFilmic::new(),
SplitConfig {
luma_weights: LUMA_BT709,
max_log2: 10.0,
..Default::default()
},
);
let hdr = synth_grayscale_hdr_row(16, 3, 4.0);
let mut sdr = vec![0.0; hdr.len()];
let mut gain = vec![0.0; hdr.len() / 3];
let mut rec = vec![0.0; hdr.len()];
let mut stats = SplitStats::default();
split.split_row(&hdr, &mut sdr, &mut gain, 3, &mut stats);
split.apply_row(&sdr, &gain, &mut rec, 3);
assert_eq!(stats.clipped_sdr_pixels, 0, "grayscale should never clip");
for (a, b) in hdr.iter().zip(&rec) {
assert!((a - b).abs() < 1e-4, "round-trip drift: {a} vs {b}");
}
}
fn synth_grayscale_hdr_row(pixels: usize, channels: usize, max: f32) -> Vec<f32> {
let mut row = Vec::with_capacity(pixels * channels);
for i in 0..pixels {
let y = (i as f32 / pixels.max(1) as f32) * max;
row.push(y);
row.push(y);
row.push(y);
if channels == 4 {
row.push(0.25 + (i as f32 / pixels.max(1) as f32) * 0.5);
}
}
row
}
#[test]
fn round_trip_bt2446c_grayscale_exact() {
let split = LumaGainMapSplitter::new(
Bt2446C::new(1000.0, 100.0),
SplitConfig {
luma_weights: LUMA_BT709,
max_log2: 10.0,
..Default::default()
},
);
let hdr = synth_grayscale_hdr_row(16, 3, 4.0);
let mut sdr = vec![0.0; hdr.len()];
let mut gain = vec![0.0; hdr.len() / 3];
let mut rec = vec![0.0; hdr.len()];
let mut stats = SplitStats::default();
split.split_row(&hdr, &mut sdr, &mut gain, 3, &mut stats);
split.apply_row(&sdr, &gain, &mut rec, 3);
assert_eq!(stats.clipped_sdr_pixels, 0, "grayscale should never clip");
for (a, b) in hdr.iter().zip(&rec) {
assert!((a - b).abs() < 1e-4, "round-trip drift: {a} vs {b}");
}
}
#[test]
fn round_trip_qualifying_curves_grayscale_exact() {
let cfg = SplitConfig {
luma_weights: LUMA_BT2020,
max_log2: 10.0,
..Default::default()
};
let hdr = synth_grayscale_hdr_row(16, 3, 1.5);
let curves: Vec<(alloc::boxed::Box<dyn LumaToneMap>, &'static str)> = vec![
(
alloc::boxed::Box::new(Bt2446A::new(1000.0, 100.0)),
"Bt2446A",
),
(
alloc::boxed::Box::new(Bt2446B::new(1000.0, 100.0)),
"Bt2446B",
),
(
alloc::boxed::Box::new(Bt2408Yrgb::with_luma(4000.0, 1000.0, LUMA_BT2020)),
"Bt2408Yrgb",
),
(
alloc::boxed::Box::new(ExtendedReinhardLuma::new(4.0, LUMA_BT2020)),
"ExtendedReinhardLuma",
),
(
alloc::boxed::Box::new(CompiledFilmicSpline::with_luma(
&FilmicSplineConfig::default(),
LUMA_BT2020,
)),
"CompiledFilmicSpline",
),
];
for (curve, name) in curves {
let split = LumaGainMapSplitter::new(curve, cfg);
let mut sdr = vec![0.0; hdr.len()];
let mut gain = vec![0.0; hdr.len() / 3];
let mut rec = vec![0.0; hdr.len()];
let mut stats = SplitStats::default();
split.split_row(&hdr, &mut sdr, &mut gain, 3, &mut stats);
split.apply_row(&sdr, &gain, &mut rec, 3);
assert_eq!(
stats.clipped_sdr_pixels, 0,
"{name}: grayscale should never clip"
);
for (i, (a, b)) in hdr.iter().zip(&rec).enumerate() {
assert!((a - b).abs() < 1e-4, "{name}: drift at [{i}]: {a} vs {b}");
}
}
}
#[test]
fn chromatic_hdr_clipped_pixels_isolated() {
let split = LumaGainMapSplitter::new(
Bt2446C::new(1000.0, 100.0),
SplitConfig {
luma_weights: LUMA_BT709,
max_log2: 10.0,
..Default::default()
},
);
let hdr: Vec<f32> = vec![
0.5, 0.5, 0.5, 0.1, 0.1, 0.1, 4.0, 0.05, 0.05, ];
let mut sdr = vec![0.0; hdr.len()];
let mut gain = vec![0.0; hdr.len() / 3];
let mut rec = vec![0.0; hdr.len()];
let mut stats = SplitStats::default();
split.split_row(&hdr, &mut sdr, &mut gain, 3, &mut stats);
split.apply_row(&sdr, &gain, &mut rec, 3);
assert!(
stats.clipped_sdr_pixels >= 1,
"expected the saturated pixel to clip, stats: {stats:?}"
);
for i in 0..6 {
assert!(
(hdr[i] - rec[i]).abs() < 1e-4,
"grayscale drift at [{i}]: {} vs {}",
hdr[i],
rec[i]
);
}
for v in &rec[6..9] {
assert!(v.is_finite() && *v >= 0.0, "pixel 2 invalid: {v}");
}
}
#[test]
fn rgba_alpha_passthrough() {
let split = LumaGainMapSplitter::new(Bt2446C::new(1000.0, 100.0), SplitConfig::default());
let hdr = synth_grayscale_hdr_row(4, 4, 0.6);
let mut sdr = vec![0.0; hdr.len()];
let mut gain = vec![0.0; hdr.len() / 4];
let mut rec = vec![0.0; hdr.len()];
let mut stats = SplitStats::default();
split.split_row(&hdr, &mut sdr, &mut gain, 4, &mut stats);
split.apply_row(&sdr, &gain, &mut rec, 4);
for (i, src) in hdr.chunks_exact(4).enumerate() {
assert_eq!(sdr[i * 4 + 3], src[3], "sdr alpha drift at pixel {i}");
assert_eq!(rec[i * 4 + 3], src[3], "rec alpha drift at pixel {i}");
}
}
#[test]
fn extreme_highlights_clamp_gracefully() {
let split = LumaGainMapSplitter::new(
Bt2446C::new(1000.0, 100.0),
SplitConfig {
max_log2: 3.0, ..Default::default()
},
);
let hdr = vec![100.0_f32, 100.0, 100.0, 0.5, 0.5, 0.5];
let mut sdr = vec![0.0; hdr.len()];
let mut gain = vec![0.0; hdr.len() / 3];
let mut stats = SplitStats::default();
split.split_row(&hdr, &mut sdr, &mut gain, 3, &mut stats);
for v in &sdr {
assert!((0.0..=1.0).contains(v), "SDR out of range: {v}");
}
for v in &gain {
assert!((0.0..=3.0).contains(v), "gain out of clamp: {v}");
}
assert!(
stats.observed_max_log2 > 3.0,
"observed_max_log2 should report pre-clamp value, got {}",
stats.observed_max_log2
);
}
#[test]
fn pq_round_trip_grayscale() {
let split = LumaGainMapSplitter::new(
Bt2446C::new(1000.0, 100.0),
SplitConfig {
luma_weights: LUMA_BT2020,
..Default::default()
},
);
let pq: Vec<f32> = (0..8)
.flat_map(|i| {
let v = i as f32 / 7.0 * 0.75; [v, v, v]
})
.collect();
let mut sdr = vec![0.0; pq.len()];
let mut gain = vec![0.0; pq.len() / 3];
let mut rec = vec![0.0; pq.len()];
let mut stats = SplitStats::default();
split.split_pq_row(&pq, &mut sdr, &mut gain, 3, 1000.0, &mut stats);
split.apply_pq_row(&sdr, &gain, &mut rec, 3, 1000.0);
for (i, (a, b)) in pq.iter().zip(&rec).enumerate() {
assert!(
(a - b).abs() < 1e-3,
"PQ round-trip drift at [{i}]: {a} vs {b}"
);
}
}
#[test]
fn pq_helpers_round_trip() {
let data: Vec<f32> = (0..15).map(|i| i as f32 / 14.0).collect();
let mut roundtrip = data.clone();
pq_to_normalized_linear_row(&mut roundtrip, 3, 1000.0);
normalized_linear_to_pq_row(&mut roundtrip, 3, 1000.0);
for (i, (a, b)) in data.iter().zip(&roundtrip).enumerate() {
assert!((a - b).abs() < 1e-3, "PQ helper drift at [{i}]: {a} vs {b}");
}
}
#[test]
fn pq_helper_alpha_untouched() {
let mut row: Vec<f32> = vec![0.5, 0.5, 0.5, 0.42, 0.7, 0.7, 0.7, 0.99];
pq_to_normalized_linear_row(&mut row, 4, 1000.0);
assert!((row[3] - 0.42).abs() < 1e-9);
assert!((row[7] - 0.99).abs() < 1e-9);
}
#[test]
fn hlg_round_trip_grayscale() {
let split = LumaGainMapSplitter::new(
Bt2446B::new(1000.0, 100.0),
SplitConfig {
luma_weights: LUMA_BT2020,
..Default::default()
},
);
let hlg: Vec<f32> = (0..8)
.flat_map(|i| {
let v = 0.1 + i as f32 / 7.0 * 0.8;
[v, v, v]
})
.collect();
let mut sdr = vec![0.0; hlg.len()];
let mut gain = vec![0.0; hlg.len() / 3];
let mut rec = vec![0.0; hlg.len()];
let mut stats = SplitStats::default();
split.split_hlg_row(&hlg, &mut sdr, &mut gain, 3, 1000.0, 1000.0, &mut stats);
split.apply_hlg_row(&sdr, &gain, &mut rec, 3, 1000.0, 1000.0);
for (i, (a, b)) in hlg.iter().zip(&rec).enumerate() {
assert!(
(a - b).abs() < 5e-3,
"HLG round-trip drift at [{i}]: {a} vs {b}"
);
}
}
#[test]
fn hlg_helpers_round_trip() {
let data: Vec<f32> = (0..15).map(|i| 0.05 + i as f32 / 14.0 * 0.9).collect();
let mut roundtrip = data.clone();
hlg_to_normalized_linear_row(&mut roundtrip, 3, 1000.0, 1000.0);
normalized_linear_to_hlg_row(&mut roundtrip, 3, 1000.0, 1000.0);
for (i, (a, b)) in data.iter().zip(&roundtrip).enumerate() {
assert!(
(a - b).abs() < 5e-3,
"HLG helper drift at [{i}]: {a} vs {b}"
);
}
}
#[test]
fn pre_desaturation_reduces_clipping() {
let hdr: Vec<f32> = vec![
4.0, 0.05, 0.05, 0.05, 4.0, 0.05, 0.05, 0.05, 4.0, ];
let no_desat = LumaGainMapSplitter::new(
Bt2446C::new(1000.0, 100.0),
SplitConfig {
pre_desaturate: 0.0,
..Default::default()
},
);
let with_desat = LumaGainMapSplitter::new(
Bt2446C::new(1000.0, 100.0),
SplitConfig {
pre_desaturate: 0.10,
..Default::default()
},
);
let mut sdr_a = vec![0.0; hdr.len()];
let mut sdr_b = vec![0.0; hdr.len()];
let mut gain_a = vec![0.0; hdr.len() / 3];
let mut gain_b = vec![0.0; hdr.len() / 3];
let mut stats_a = SplitStats::default();
let mut stats_b = SplitStats::default();
no_desat.split_row(&hdr, &mut sdr_a, &mut gain_a, 3, &mut stats_a);
with_desat.split_row(&hdr, &mut sdr_b, &mut gain_b, 3, &mut stats_b);
assert!(
stats_a.clipped_sdr_pixels > 0,
"saturated pixels should clip without desaturation"
);
assert!(
stats_b.clipped_sdr_pixels <= stats_a.clipped_sdr_pixels,
"desaturation should reduce or equal clipping: {} vs {}",
stats_b.clipped_sdr_pixels,
stats_a.clipped_sdr_pixels
);
}
#[test]
fn pre_desaturation_grayscale_exact() {
let split = LumaGainMapSplitter::new(
Bt2446C::new(1000.0, 100.0),
SplitConfig {
pre_desaturate: 0.15,
..Default::default()
},
);
let hdr = synth_grayscale_hdr_row(16, 3, 2.0);
let mut sdr = vec![0.0; hdr.len()];
let mut gain = vec![0.0; hdr.len() / 3];
let mut rec = vec![0.0; hdr.len()];
let mut stats = SplitStats::default();
split.split_row(&hdr, &mut sdr, &mut gain, 3, &mut stats);
split.apply_row(&sdr, &gain, &mut rec, 3);
assert_eq!(stats.clipped_sdr_pixels, 0);
for (a, b) in hdr.iter().zip(&rec) {
assert!(
(a - b).abs() < 1e-4,
"grayscale drift with desaturation: {a} vs {b}"
);
}
}
#[test]
#[should_panic(expected = "channels must be 3 or 4")]
fn bad_channel_count_panics() {
let split = LumaGainMapSplitter::new(Bt2446C::new(1000.0, 100.0), SplitConfig::default());
let mut sdr = [0.0_f32; 12];
let mut gain = [0.0_f32; 6];
let mut stats = SplitStats::default();
split.split_row(&[0.0_f32; 12], &mut sdr, &mut gain, 2, &mut stats);
}
}