Files
craft-libs/crates/cms/src/clut.rs
T
Dylan M. Taylor bc8fe0eb53 cms: keep NaN propagation in multilinear interpolation
A NaN input gives a NaN fraction, which the active-dimension check dropped, returning a
finite node value. Keep any nonzero fraction, NaN included (CodeRabbit review).
2026-10-22 22:22:39 -04:00

354 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Multi-dimensional colour lookup tables (ICC CLUTs) with tetrahedral interpolation.
//!
//! Grid layout follows ICC.1: the first input channel varies slowest, output values of one
//! node are stored together. 3-input tables use tetrahedral interpolation (Sakamoto 1980 /
//! Kasson et al. 1995); 4-input tables interpolate linearly along the first input between
//! two tetrahedral evaluations; other input counts use multilinear interpolation.
use crate::{CmsError, check_len, invalid_arg};
/// Maximum number of input or output channels of a CLUT.
pub const MAX_CHANNELS: usize = 15;
/// Largest number of stored values (nodes × outputs) a table may hold.
pub const MAX_VALUES: usize = 16 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq)]
pub struct Clut {
pub(crate) inputs: usize,
pub(crate) outputs: usize,
pub(crate) grid: Vec<usize>,
pub(crate) data: Vec<f32>,
strides: Vec<usize>,
}
impl Clut {
/// Builds a table from node data: `grid` holds the points per input dimension (1 to
/// [`MAX_CHANNELS`] dimensions, each ≥ 2, or 1 for a degenerate dimension), `data` holds
/// `outputs` values per node. Refuses inconsistent dimensions with
/// [`CmsError::InvalidArgument`].
pub fn new(grid: Vec<usize>, outputs: usize, data: Vec<f32>) -> Result<Self, CmsError> {
let total = Self::check_dims(&grid, outputs)?;
if data.len() != total {
return Err(invalid_arg(format!("CLUT data has {} values, the grid needs {total}", data.len())));
}
Ok(Self::from_checked(grid, outputs, data))
}
/// Number of values a `grid` × `outputs` table stores, or why it is refused.
fn check_dims(grid: &[usize], outputs: usize) -> Result<usize, CmsError> {
if !(1..=MAX_CHANNELS).contains(&grid.len()) || !(1..=MAX_CHANNELS).contains(&outputs) {
return Err(invalid_arg(format!("CLUT with {} inputs and {outputs} outputs (1 to {MAX_CHANNELS} each)", grid.len())));
}
if grid.contains(&0) {
return Err(invalid_arg("CLUT grid with zero points in a dimension"));
}
grid.iter()
.try_fold(outputs, |n, g| n.checked_mul(*g))
.filter(|n| *n <= MAX_VALUES)
.ok_or_else(|| invalid_arg(format!("CLUT grid {grid:?} × {outputs} exceeds {MAX_VALUES} values")))
}
fn from_checked(grid: Vec<usize>, outputs: usize, data: Vec<f32>) -> Self {
let inputs = grid.len();
let mut strides = vec![0usize; inputs];
let mut s = outputs;
for i in (0..inputs).rev() {
strides[i] = s;
s *= grid[i];
}
Clut { inputs, outputs, grid, data, strides }
}
pub fn inputs(&self) -> usize {
self.inputs
}
pub fn outputs(&self) -> usize {
self.outputs
}
/// Grid points per input dimension.
pub fn grid(&self) -> &[usize] {
&self.grid
}
/// Normalised node values, `outputs` per node, first input varying slowest.
pub fn data(&self) -> &[f32] {
&self.data
}
/// Number of nodes.
pub fn nodes(&self) -> usize {
self.data.len() / self.outputs
}
/// Builds a table by sampling `f` at every node (inputs normalised to `[0, 1]`); the
/// dimensions are checked as in [`Clut::new`].
pub fn sample(grid: Vec<usize>, outputs: usize, mut f: impl FnMut(&[f32], &mut [f32])) -> Result<Self, CmsError> {
let total = Self::check_dims(&grid, outputs)?;
let inputs = grid.len();
let mut data = vec![0.0f32; total];
let mut idx = vec![0usize; inputs];
let mut inp = vec![0.0f32; inputs];
for (node, out) in data.chunks_exact_mut(outputs).enumerate() {
let mut rem = node;
for d in (0..inputs).rev() {
idx[d] = rem % grid[d];
rem /= grid[d];
}
for d in 0..inputs {
inp[d] = if grid[d] > 1 { idx[d] as f32 / (grid[d] - 1) as f32 } else { 0.0 };
}
f(&inp, out);
}
Ok(Self::from_checked(grid, outputs, data))
}
#[inline]
fn locate(&self, d: usize, v: f32) -> (usize, f32, usize) {
let n = self.grid[d];
if n < 2 {
return (0, 0.0, 0);
}
let p = v.clamp(0.0, 1.0) * (n - 1) as f32;
let i = (p as usize).min(n - 2);
(i * self.strides[d], p - i as f32, self.strides[d])
}
/// Interpolates at `input` (at least `inputs` values) into `out` (at least `outputs`
/// values).
pub fn eval(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> {
self.check_io(input, out)?;
self.interp(input, out);
Ok(())
}
fn check_io(&self, input: &[f32], out: &[f32]) -> Result<(), CmsError> {
check_len("CLUT input", input.len(), self.inputs)?;
check_len("CLUT output", out.len(), self.outputs)
}
/// [`Clut::eval`] for callers that guarantee the slice lengths.
#[inline]
pub(crate) fn interp(&self, input: &[f32], out: &mut [f32]) {
match self.inputs {
1 => self.eval1(input[0], out),
3 => {
let (b0, r0, s0) = self.locate(0, input[0]);
let (b1, r1, s1) = self.locate(1, input[1]);
let (b2, r2, s2) = self.locate(2, input[2]);
self.tetra(b0 + b1 + b2, [s0, s1, s2], [r0, r1, r2], out);
}
4 => {
let (b0, r0, s0) = self.locate(0, input[0]);
let (b1, r1, s1) = self.locate(1, input[1]);
let (b2, r2, s2) = self.locate(2, input[2]);
let (b3, r3, s3) = self.locate(3, input[3]);
let base = b0 + b1 + b2 + b3;
let mut lo = [0.0f32; MAX_CHANNELS];
self.tetra(base, [s1, s2, s3], [r1, r2, r3], &mut lo[..self.outputs]);
if r0 > 0.0 && s0 > 0 {
let mut hi = [0.0f32; MAX_CHANNELS];
self.tetra(base + s0, [s1, s2, s3], [r1, r2, r3], &mut hi[..self.outputs]);
for (o, (l, h)) in out.iter_mut().zip(lo.iter().zip(&hi)) {
*o = l + (h - l) * r0;
}
} else {
out[..self.outputs].copy_from_slice(&lo[..self.outputs]);
}
}
_ => self.multilinear(input, out),
}
}
fn eval1(&self, v: f32, out: &mut [f32]) {
let (b, r, s) = self.locate(0, v);
for (k, o) in out.iter_mut().enumerate().take(self.outputs) {
let a = self.data[b + k];
let c = if s > 0 { self.data[b + s + k] } else { a };
*o = a + (c - a) * r;
}
}
/// Tetrahedral interpolation inside one cube of three dimensions.
#[inline]
fn tetra(&self, base: usize, s: [usize; 3], r: [f32; 3], out: &mut [f32]) {
let d = &self.data;
let [rx, ry, rz] = r;
let [sx, sy, sz] = s;
let c000 = base;
let c100 = base + sx;
let c010 = base + sy;
let c001 = base + sz;
let c110 = base + sx + sy;
let c101 = base + sx + sz;
let c011 = base + sy + sz;
let c111 = base + sx + sy + sz;
// Pick the tetrahedron containing (rx, ry, rz) and the path through the cube corners.
let (p1, p2, p3, w1, w2, w3) = if rx >= ry {
if ry >= rz {
(c100, c110, c111, rx, ry, rz)
} else if rx >= rz {
(c100, c101, c111, rx, rz, ry)
} else {
(c001, c101, c111, rz, rx, ry)
}
} else if rx >= rz {
(c010, c110, c111, ry, rx, rz)
} else if ry >= rz {
(c010, c011, c111, ry, rz, rx)
} else {
(c001, c011, c111, rz, ry, rx)
};
for (k, o) in out.iter_mut().enumerate().take(self.outputs) {
let v0 = d[c000 + k];
let v1 = d[p1 + k];
let v2 = d[p2 + k];
let v3 = d[p3 + k];
*o = v0 + (v1 - v0) * w1 + (v2 - v1) * w2 + (v3 - v2) * w3;
}
}
/// Trilinear interpolation (3 inputs), kept as a reference for accuracy comparisons.
pub fn eval_trilinear(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> {
if self.inputs != 3 {
return Err(invalid_arg(format!("trilinear interpolation of a {}-input CLUT", self.inputs)));
}
self.eval_multilinear(input, out)
}
/// Multilinear interpolation over all 2^n cube corners.
pub fn eval_multilinear(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> {
self.check_io(input, out)?;
self.multilinear(input, out);
Ok(())
}
fn multilinear(&self, input: &[f32], out: &mut [f32]) {
// Only dimensions that sit between two grid points spread over corners. A singleton
// dimension, or an input on a node, takes its lower corner with weight 1, so a table of
// many such dimensions costs no more than the dimensions that actually interpolate.
let mut base = 0usize;
let mut r = [0.0f32; MAX_CHANNELS];
let mut s = [0usize; MAX_CHANNELS];
let mut n = 0;
for (d, &v) in input.iter().enumerate().take(self.inputs) {
let (b, rr, ss) = self.locate(d, v);
base += b;
// `!= 0.0` keeps a NaN fraction, so a NaN input still gives a NaN result.
if ss > 0 && rr != 0.0 {
r[n] = rr;
s[n] = ss;
n += 1;
}
}
let o = self.outputs;
out[..o].iter_mut().for_each(|v| *v = 0.0);
for corner in 0..(1usize << n) {
let mut w = 1.0f32;
let mut off = base;
for d in 0..n {
if corner & (1 << d) != 0 {
w *= r[d];
off += s[d];
} else {
w *= 1.0 - r[d];
}
}
if w == 0.0 {
continue;
}
for (k, v) in out[..o].iter_mut().enumerate() {
*v += w * self.data[off + k];
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn f3(p: &[f32], o: &mut [f32]) {
o[0] = p[0] * 0.5 + p[1] * 0.3 + p[2] * 0.2;
o[1] = p[0] * p[1];
o[2] = (p[2] * 3.0).sin() * 0.5 + 0.5;
}
#[test]
fn exact_on_nodes_and_linear_functions() {
let c = Clut::sample(vec![5, 5, 5], 3, f3).unwrap();
let mut o = [0.0; 3];
c.eval(&[0.25, 0.5, 0.75], &mut o).unwrap();
let mut w = [0.0; 3];
f3(&[0.25, 0.5, 0.75], &mut w);
for i in 0..3 {
assert!((o[i] - w[i]).abs() < 1e-6);
}
// Linear function is reproduced exactly anywhere.
c.eval(&[0.13, 0.77, 0.41], &mut o).unwrap();
assert!((o[0] - (0.13 * 0.5 + 0.77 * 0.3 + 0.41 * 0.2)).abs() < 1e-6);
}
#[test]
fn four_d_and_multilinear_agree_on_linear() {
let f = |p: &[f32], o: &mut [f32]| o[0] = 0.1 * p[0] + 0.2 * p[1] + 0.3 * p[2] + 0.4 * p[3];
let c = Clut::sample(vec![3, 4, 5, 6], 1, f).unwrap();
let mut a = [0.0];
let mut b = [0.0];
for p in [[0.1f32, 0.9, 0.3, 0.7], [1.0, 0.0, 0.5, 0.5], [0.33, 0.66, 0.99, 0.01]] {
c.eval(&p, &mut a).unwrap();
c.eval_multilinear(&p, &mut b).unwrap();
let want = 0.1 * p[0] + 0.2 * p[1] + 0.3 * p[2] + 0.4 * p[3];
assert!((a[0] - want).abs() < 1e-5 && (b[0] - want).abs() < 1e-5);
}
}
#[test]
fn one_and_two_d() {
let c = Clut::sample(vec![3], 2, |p, o| {
o[0] = p[0];
o[1] = 1.0 - p[0];
})
.unwrap();
let mut o = [0.0; 2];
c.eval(&[0.25], &mut o).unwrap();
assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.75).abs() < 1e-6);
let c = Clut::sample(vec![2, 2], 1, |p, o| o[0] = p[0] + p[1]).unwrap();
c.eval(&[0.5, 0.25], &mut o).unwrap();
assert!((o[0] - 0.75).abs() < 1e-6);
}
#[test]
fn a_nan_input_still_gives_nan() {
let c = Clut::sample(vec![2; 5], 1, |p, o| o[0] = p.iter().sum()).unwrap();
let mut o = [0.0];
c.eval_multilinear(&[0.5, f32::NAN, 0.5, 0.5, 0.5], &mut o).unwrap();
assert!(o[0].is_nan(), "{o:?}");
}
#[test]
fn singleton_dimensions_do_not_multiply_corners() {
// Fifteen inputs, all but two on a single grid point: the result interpolates only those
// two, and matches the same table built with two inputs.
let mut grid = vec![1usize; 15];
grid[3] = 2;
grid[9] = 3;
let wide = Clut::sample(grid, 2, |p, o| {
o[0] = p[3] * 0.5 + p[9] * 0.25;
o[1] = p[3] * p[9];
})
.unwrap();
let narrow = Clut::sample(vec![2, 3], 2, |p, o| {
o[0] = p[0] * 0.5 + p[1] * 0.25;
o[1] = p[0] * p[1];
})
.unwrap();
let mut input = [0.4f32; 15];
input[3] = 0.3;
input[9] = 0.7;
let (mut a, mut b) = ([0.0; 2], [0.0; 2]);
wide.eval_multilinear(&input, &mut a).unwrap();
narrow.eval_multilinear(&[0.3, 0.7], &mut b).unwrap();
assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "{a:?} {b:?}");
}
}