use alloc::vec::Vec;
use crate::ToneMap;
const LUT_SIZE: usize = 4096;
#[derive(Clone, Debug)]
pub struct ProfileToneCurve {
lut: Vec<f32>,
}
impl ProfileToneCurve {
pub fn from_xy_pairs(tc_data: &[f32]) -> Option<Self> {
let n_points = tc_data.len() / 2;
if n_points < 2 {
return None;
}
if tc_data[..n_points * 2].iter().any(|v| !v.is_finite()) {
return None;
}
let points: Vec<(f32, f32)> = (0..n_points)
.map(|i| (tc_data[i * 2], tc_data[i * 2 + 1]))
.collect();
let lut: Vec<f32> = (0..=LUT_SIZE)
.map(|i| {
let x = i as f32 / LUT_SIZE as f32;
interpolate_curve(&points, x)
})
.collect();
Some(Self { lut })
}
pub fn from_lut(lut: Vec<f32>) -> Option<Self> {
if lut.len() != LUT_SIZE + 1 {
return None;
}
Some(Self { lut })
}
pub fn identity() -> Self {
let lut: Vec<f32> = (0..=LUT_SIZE).map(|i| i as f32 / LUT_SIZE as f32).collect();
Self { lut }
}
#[inline]
pub fn eval(&self, x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
let idx_f = x * LUT_SIZE as f32;
let idx = (idx_f as usize).min(LUT_SIZE - 1);
let frac = idx_f - idx as f32;
self.lut[idx] * (1.0 - frac) + self.lut[idx + 1] * frac
}
#[inline]
pub fn per_channel(&self) -> ProfilePerChannel<'_> {
ProfilePerChannel { curve: self }
}
#[inline]
pub fn luminance(&self, luma: [f32; 3]) -> ProfileLuminance<'_> {
ProfileLuminance { curve: self, luma }
}
}
#[derive(Clone, Copy, Debug)]
pub struct ProfilePerChannel<'a> {
curve: &'a ProfileToneCurve,
}
impl ToneMap for ProfilePerChannel<'_> {
#[inline]
fn map_rgb(&self, rgb: [f32; 3]) -> [f32; 3] {
[
self.curve.eval(rgb[0]),
self.curve.eval(rgb[1]),
self.curve.eval(rgb[2]),
]
}
}
#[derive(Clone, Copy, Debug)]
pub struct ProfileLuminance<'a> {
curve: &'a ProfileToneCurve,
luma: [f32; 3],
}
impl ToneMap for ProfileLuminance<'_> {
#[inline]
fn map_rgb(&self, rgb: [f32; 3]) -> [f32; 3] {
let lum = rgb[0] * self.luma[0] + rgb[1] * self.luma[1] + rgb[2] * self.luma[2];
if lum <= 1e-10 {
return [0.0, 0.0, 0.0];
}
let mapped = self.curve.eval(lum.min(1.0));
let ratio = mapped / lum;
[
(rgb[0] * ratio).min(1.0),
(rgb[1] * ratio).min(1.0),
(rgb[2] * ratio).min(1.0),
]
}
}
fn interpolate_curve(points: &[(f32, f32)], x: f32) -> f32 {
if points.is_empty() {
return x;
}
if x <= points[0].0 {
return points[0].1;
}
if x >= points[points.len() - 1].0 {
return points[points.len() - 1].1;
}
let mut lo = 0;
let mut hi = points.len() - 1;
while hi - lo > 1 {
let mid = (lo + hi) / 2;
if points[mid].0 <= x {
lo = mid;
} else {
hi = mid;
}
}
let dx = points[hi].0 - points[lo].0;
if dx <= 0.0 {
return points[lo].1;
}
let t = (x - points[lo].0) / dx;
points[lo].1 * (1.0 - t) + points[hi].1 * t
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LUMA_BT709;
use alloc::vec;
#[test]
fn identity_passes_through() {
let curve = ProfileToneCurve::identity();
for i in 0..=10 {
let x = i as f32 / 10.0;
assert!(
(curve.eval(x) - x).abs() < 1e-3,
"identity eval({x}) = {}",
curve.eval(x)
);
}
}
#[test]
fn from_pairs_matches_controls_roughly() {
let pts = vec![0.0_f32, 0.0, 0.5, 0.25, 1.0, 1.0];
let curve = ProfileToneCurve::from_xy_pairs(&pts).unwrap();
assert!(curve.eval(0.0) < 1e-3);
assert!((curve.eval(0.5) - 0.25).abs() < 1e-2);
assert!((curve.eval(1.0) - 1.0).abs() < 1e-3);
}
#[test]
fn from_lut_wrong_length_rejected() {
let short = vec![0.0_f32; 100];
assert!(ProfileToneCurve::from_lut(short).is_none());
}
#[test]
fn per_channel_view_preserves_alpha() {
let curve = ProfileToneCurve::identity();
let mut row = [0.5_f32, 0.5, 0.5, 0.42];
curve.per_channel().map_row(&mut row, 4);
assert!((row[3] - 0.42).abs() < 1e-6);
}
#[test]
fn luminance_view_preserves_alpha() {
let curve = ProfileToneCurve::identity();
let mut row = [0.3_f32, 0.5, 0.2, 0.77];
curve.luminance(LUMA_BT709).map_row(&mut row, 4);
assert!((row[3] - 0.77).abs() < 1e-6);
}
#[test]
fn luminance_view_preserves_hue_on_identity() {
let curve = ProfileToneCurve::identity();
let view = curve.luminance(LUMA_BT709);
let out = view.map_rgb([0.3, 0.6, 0.2]);
for c in out {
assert!((0.0..=1.0).contains(&c));
}
}
fn dng_shape_257_points() -> alloc::vec::Vec<f32> {
let mut pts = alloc::vec::Vec::with_capacity(257 * 2);
for i in 0..257 {
let x = i as f32 / 256.0;
let y = x * x * (3.0 - 2.0 * x); pts.push(x);
pts.push(y);
}
pts
}
#[test]
fn realistic_257_point_curve_is_monotonic_and_smooth() {
let curve = ProfileToneCurve::from_xy_pairs(&dng_shape_257_points()).unwrap();
let mut last = curve.eval(0.0);
for i in 1..=100 {
let x = i as f32 / 100.0;
let y = curve.eval(x);
assert!(
y >= last - 1e-6,
"profile curve not monotonic at x={x}: {y} < previous {last}"
);
last = y;
}
assert!(curve.eval(0.0).abs() < 1e-3);
assert!((curve.eval(1.0) - 1.0).abs() < 1e-3);
assert!((curve.eval(0.5) - 0.5).abs() < 1e-2);
}
#[test]
fn per_channel_view_applies_curve_independently() {
let curve = ProfileToneCurve::from_xy_pairs(&dng_shape_257_points()).unwrap();
let view = curve.per_channel();
let out = view.map_rgb([0.25, 0.5, 0.75]);
assert!((out[0] - curve.eval(0.25)).abs() < 1e-6);
assert!((out[1] - curve.eval(0.50)).abs() < 1e-6);
assert!((out[2] - curve.eval(0.75)).abs() < 1e-6);
}
#[test]
fn luminance_view_ratio_preserves_relative_rgb() {
let curve = ProfileToneCurve::from_xy_pairs(&dng_shape_257_points()).unwrap();
let view = curve.luminance(LUMA_BT709);
let rgb = [0.2_f32, 0.4, 0.1];
let out = view.map_rgb(rgb);
let ratio_in = rgb[0] / rgb[1];
let ratio_out = out[0] / out[1];
assert!(
(ratio_in - ratio_out).abs() < 1e-3,
"hue drift in luminance-preserving mode: in ratio {ratio_in}, out ratio {ratio_out}"
);
}
#[test]
fn from_lut_roundtrip() {
let mut lut = alloc::vec::Vec::with_capacity(4097);
for i in 0..=4096 {
let x = i as f32 / 4096.0;
lut.push(x * 0.8); }
let curve = ProfileToneCurve::from_lut(lut).unwrap();
for probe in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
let y = curve.eval(probe);
assert!(
(y - probe * 0.8).abs() < 1e-3,
"from_lut eval({probe}) = {y}, expected {}",
probe * 0.8
);
}
}
#[test]
fn from_xy_pairs_rejects_single_point() {
let too_short = alloc::vec![0.5_f32, 0.5];
assert!(ProfileToneCurve::from_xy_pairs(&too_short).is_none());
}
}