Skip to main content

qualia_core_db/render/
metamer.rs

1//! P7.1 — Metamers as the affine fibre of the colour-matching projection.
2//!
3//! Two SPDs are metameric if they project to the same XYZ tristimulus values
4//! under the CIE CMFs. The set of all SPDs mapping to a given XYZ is an
5//! affine fibre: `particular + span(kernel)`.
6//!
7//! ## Linear algebra
8//!
9//! The CMF projection is a linear map `P: R^41 → R^3` (SPD → XYZ).
10//! - **Particular solution**: `spd = P⁺ · xyz` (pseudo-inverse)
11//! - **Kernel basis**: `ker(P)` = all SPDs that project to (0,0,0)
12//! - **Fibre**: `spd = particular + Σ c_i · ker_i` for any coefficients `c_i`
13//!
14//! A metameric-black SPD is an element of the kernel (projects to zero).
15//!
16//! ## Determinism
17//!
18//! All operations are deterministic: the CMF matrix is a compile-time
19//! constant, and the pseudo-inverse is computed via fixed-point iteration.
20
21use super::spectral_kernel::{
22    spd_to_xyz, Spd, Xyz, CIE_1931_CMF_X, CIE_1931_CMF_Y, CIE_1931_CMF_Z, SPD_SAMPLES,
23};
24
25/// Normalisation factor used by `spd_to_xyz` (1 / Σȳ).
26/// Computed manually since `iter().sum()` is not const.
27const Y_NORM: f32 = {
28    let mut sum = 0.0f32;
29    let mut i = 0;
30    while i < CIE_1931_CMF_Y.len() {
31        sum += CIE_1931_CMF_Y[i];
32        i += 1;
33    }
34    1.0 / sum
35};
36
37// ───────────────────────────────────────────────────────────────────────────
38//  Errors
39// ───────────────────────────────────────────────────────────────────────────
40
41/// Metamer computation error.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum MetamerError {
44    /// Target XYZ is zero (degenerate).
45    ZeroTarget,
46    /// Buffer too small.
47    BufferTooSmall { needed: usize, have: usize },
48}
49
50impl core::fmt::Display for MetamerError {
51    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
52        match self {
53            Self::ZeroTarget => write!(f, "metamer: target XYZ is zero"),
54            Self::BufferTooSmall { needed, have } => {
55                write!(f, "metamer: buffer too small, need {needed}, have {have}")
56            }
57        }
58    }
59}
60
61impl std::error::Error for MetamerError {}
62
63// ───────────────────────────────────────────────────────────────────────────
64//  Kernel basis (metameric black)
65// ───────────────────────────────────────────────────────────────────────────
66
67/// Compute a basis for the kernel of the CMF projection (metameric-black SPDs).
68///
69/// The kernel has dimension `SPD_SAMPLES - 3 = 38`. We compute it via
70/// Gram-Schmidt orthogonalisation against the three CMF rows.
71///
72/// `out_basis` needs `(SPD_SAMPLES - 3) * SPD_SAMPLES` entries (row-major).
73/// Returns the number of basis vectors written.
74pub fn metamer_kernel_basis(out_basis: &mut [f32]) -> Result<usize, MetamerError> {
75    let n = SPD_SAMPLES;
76    let ker_dim = n - 3;
77    if out_basis.len() < ker_dim * n {
78        return Err(MetamerError::BufferTooSmall {
79            needed: ker_dim * n,
80            have: out_basis.len(),
81        });
82    }
83
84    // The three CMF rows as f64 vectors.
85    let cmf_f64: [[f64; SPD_SAMPLES]; 3] = [
86        CIE_1931_CMF_X.map(|v| v as f64),
87        CIE_1931_CMF_Y.map(|v| v as f64),
88        CIE_1931_CMF_Z.map(|v| v as f64),
89    ];
90
91    // Compute P P^T (3×3) in f64.
92    let mut ppt = [[0.0f64; 3]; 3];
93    for i in 0..3 {
94        for j in 0..3 {
95            ppt[i][j] = cmf_f64[i]
96                .iter()
97                .zip(cmf_f64[j].iter())
98                .map(|(a, b)| a * b)
99                .sum();
100        }
101    }
102
103    // Invert the 3×3 matrix in f64.
104    let det = ppt[0][0] * (ppt[1][1] * ppt[2][2] - ppt[1][2] * ppt[2][1])
105        - ppt[0][1] * (ppt[1][0] * ppt[2][2] - ppt[1][2] * ppt[2][0])
106        + ppt[0][2] * (ppt[1][0] * ppt[2][1] - ppt[1][1] * ppt[2][0]);
107    let inv_det = 1.0 / det;
108    let inv = [
109        [
110            (ppt[1][1] * ppt[2][2] - ppt[1][2] * ppt[2][1]) * inv_det,
111            (ppt[0][2] * ppt[2][1] - ppt[0][1] * ppt[2][2]) * inv_det,
112            (ppt[0][1] * ppt[1][2] - ppt[0][2] * ppt[1][1]) * inv_det,
113        ],
114        [
115            (ppt[1][2] * ppt[2][0] - ppt[1][0] * ppt[2][2]) * inv_det,
116            (ppt[0][0] * ppt[2][2] - ppt[0][2] * ppt[2][0]) * inv_det,
117            (ppt[0][2] * ppt[1][0] - ppt[0][0] * ppt[1][2]) * inv_det,
118        ],
119        [
120            (ppt[1][0] * ppt[2][1] - ppt[1][1] * ppt[2][0]) * inv_det,
121            (ppt[0][1] * ppt[2][0] - ppt[0][0] * ppt[2][1]) * inv_det,
122            (ppt[0][0] * ppt[1][1] - ppt[0][1] * ppt[1][0]) * inv_det,
123        ],
124    ];
125
126    // For each standard basis vector e_i (i=3..40), compute the null-space
127    // component: k_i = e_i - P^T (P P^T)^{-1} P e_i
128    // P e_i = (cmf_x[i], cmf_y[i], cmf_z[i])
129    // (P P^T)^{-1} P e_i = inv · (cmf_x[i], cmf_y[i], cmf_z[i])
130    // P^T · that = Σ_j cmf_j[k] * inv[j] · (P e_i)_j
131    let mut basis_idx = 0usize;
132    for i in 3..n {
133        // P e_i
134        let pe = [cmf_f64[0][i], cmf_f64[1][i], cmf_f64[2][i]];
135
136        // (P P^T)^{-1} P e_i
137        let mut inv_pe = [0.0f64; 3];
138        for j in 0..3 {
139            inv_pe[j] = inv[j][0] * pe[0] + inv[j][1] * pe[1] + inv[j][2] * pe[2];
140        }
141
142        // P^T (P P^T)^{-1} P e_i — the projection onto the row space.
143        let mut proj = [0.0f64; SPD_SAMPLES];
144        for k in 0..n {
145            for j in 0..3 {
146                proj[k] += cmf_f64[j][k] * inv_pe[j];
147            }
148        }
149
150        // k_i = e_i - proj
151        let mut v = [0.0f64; SPD_SAMPLES];
152        v[i] = 1.0;
153        for k in 0..n {
154            v[k] -= proj[k];
155        }
156
157        // Normalise.
158        let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
159        if norm > 1e-10 {
160            for j in 0..n {
161                v[j] /= norm;
162            }
163            for j in 0..n {
164                out_basis[basis_idx * n + j] = v[j] as f32;
165            }
166            basis_idx += 1;
167        }
168
169        if basis_idx >= ker_dim {
170            break;
171        }
172    }
173
174    Ok(basis_idx)
175}
176
177// ───────────────────────────────────────────────────────────────────────────
178//  Particular solution (minimum-norm SPD for a target XYZ)
179// ───────────────────────────────────────────────────────────────────────────
180
181/// Compute the minimum-norm particular solution: the SPD with least energy
182/// that projects to the target XYZ.
183///
184/// Uses the pseudo-inverse `P⁺ = P^T (P P^T)^{-1}` where P is the 3×41 CMF
185/// matrix (including the Y normalisation). Since P P^T is 3×3, we invert
186/// it directly.
187pub fn min_norm_spd_for_xyz(target: &Xyz) -> Spd {
188    // P is 3×41: rows are CIE_1931_CMF_X, CIE_1931_CMF_Y, CIE_1931_CMF_Z,
189    // each scaled by Y_NORM (matching spd_to_xyz's normalisation).
190    let cmf_x: [f32; SPD_SAMPLES] = CIE_1931_CMF_X.map(|v| v * Y_NORM);
191    let cmf_y: [f32; SPD_SAMPLES] = CIE_1931_CMF_Y.map(|v| v * Y_NORM);
192    let cmf_z: [f32; SPD_SAMPLES] = CIE_1931_CMF_Z.map(|v| v * Y_NORM);
193    let cmf = [cmf_x, cmf_y, cmf_z];
194
195    // Compute P P^T (3×3).
196    let mut ppt = [[0.0f32; 3]; 3];
197    for i in 0..3 {
198        for j in 0..3 {
199            ppt[i][j] = cmf[i].iter().zip(cmf[j].iter()).map(|(a, b)| a * b).sum();
200        }
201    }
202
203    // Invert the 3×3 matrix.
204    let inv = invert_3x3(&ppt);
205
206    // Compute P^T (P P^T)^{-1} xyz = Σ_j cmf_j * inv[j] · target
207    let target_vec = [target.x, target.y, target.z];
208    let mut spd = Spd::default();
209    for i in 0..SPD_SAMPLES {
210        let mut val = 0.0f32;
211        for j in 0..3 {
212            val += cmf[j][i]
213                * (inv[j][0] * target_vec[0]
214                    + inv[j][1] * target_vec[1]
215                    + inv[j][2] * target_vec[2]);
216        }
217        spd.samples[i] = val;
218    }
219
220    spd
221}
222
223/// Invert a 3×3 matrix.
224fn invert_3x3(m: &[[f32; 3]; 3]) -> [[f32; 3]; 3] {
225    let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
226        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
227        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
228
229    if det.abs() < 1e-20 {
230        return [[0.0; 3]; 3];
231    }
232
233    let inv_det = 1.0 / det;
234    [
235        [
236            (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
237            (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
238            (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
239        ],
240        [
241            (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
242            (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
243            (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
244        ],
245        [
246            (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
247            (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
248            (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
249        ],
250    ]
251}
252
253// ───────────────────────────────────────────────────────────────────────────
254//  Fibre construction
255// ───────────────────────────────────────────────────────────────────────────
256
257/// Construct a fibre element: `particular + Σ c_i · ker_i`.
258///
259/// `basis` is the kernel basis (row-major, `ker_dim * SPD_SAMPLES` entries).
260/// `coeffs` is the coefficient vector (`ker_dim` entries).
261pub fn fibre_spd(particular: &Spd, basis: &[f32], coeffs: &[f32]) -> Spd {
262    let mut spd = *particular;
263    let n = SPD_SAMPLES;
264    for (k, &c) in coeffs.iter().enumerate() {
265        if k * n + n > basis.len() {
266            break;
267        }
268        for i in 0..n {
269            spd.samples[i] += c * basis[k * n + i];
270        }
271    }
272    spd
273}
274
275/// Check if an SPD is metameric to a target XYZ (projects to the same XYZ).
276pub fn is_metameric(spd: &Spd, target: &Xyz, tolerance: f32) -> bool {
277    let xyz = spd_to_xyz(spd);
278    (xyz.x - target.x).abs() < tolerance
279        && (xyz.y - target.y).abs() < tolerance
280        && (xyz.z - target.z).abs() < tolerance
281}
282
283/// Check if an SPD is metameric-black (projects to approximately zero).
284pub fn is_metameric_black(spd: &Spd, tolerance: f32) -> bool {
285    let xyz = spd_to_xyz(spd);
286    xyz.x.abs() < tolerance && xyz.y.abs() < tolerance && xyz.z.abs() < tolerance
287}
288
289// ───────────────────────────────────────────────────────────────────────────
290//  Tests
291// ───────────────────────────────────────────────────────────────────────────
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn kernel_basis_projects_to_zero() {
299        let ker_dim = SPD_SAMPLES - 3;
300        let mut basis = vec![0.0f32; ker_dim * SPD_SAMPLES];
301        let count = metamer_kernel_basis(&mut basis).unwrap();
302        assert!(count > 0, "should produce at least one kernel vector");
303
304        // Each basis vector should project to ~zero.
305        for k in 0..count {
306            let mut spd = Spd::default();
307            spd.samples
308                .copy_from_slice(&basis[k * SPD_SAMPLES..(k + 1) * SPD_SAMPLES]);
309            let xyz = spd_to_xyz(&spd);
310            assert!(
311                is_metameric_black(&spd, 5e-2),
312                "kernel vector {} should be metameric-black, got XYZ=({:.6}, {:.6}, {:.6})",
313                k,
314                xyz.x,
315                xyz.y,
316                xyz.z
317            );
318        }
319    }
320
321    #[test]
322    fn min_norm_spd_reprojects_to_target() {
323        let target = Xyz::new(0.5, 0.6, 0.4);
324        let spd = min_norm_spd_for_xyz(&target);
325        let reprojected = spd_to_xyz(&spd);
326        assert!(
327            (reprojected.x - target.x).abs() < 0.05,
328            "X reprojection mismatch: {} vs {}",
329            reprojected.x,
330            target.x
331        );
332        assert!(
333            (reprojected.y - target.y).abs() < 0.05,
334            "Y reprojection mismatch: {} vs {}",
335            reprojected.y,
336            target.y
337        );
338        assert!(
339            (reprojected.z - target.z).abs() < 0.05,
340            "Z reprojection mismatch: {} vs {}",
341            reprojected.z,
342            target.z
343        );
344    }
345
346    #[test]
347    fn fibre_invariance() {
348        // particular + kernel element should re-project to the same XYZ.
349        let target = Xyz::new(0.3, 0.5, 0.2);
350        let particular = min_norm_spd_for_xyz(&target);
351
352        let ker_dim = SPD_SAMPLES - 3;
353        let mut basis = vec![0.0f32; ker_dim * SPD_SAMPLES];
354        let count = metamer_kernel_basis(&mut basis).unwrap();
355
356        // Add a kernel element with random-ish coefficients.
357        let coeffs: Vec<f32> = (0..count).map(|i| (i as f32 * 0.1).sin()).collect();
358        let fibre = fibre_spd(&particular, &basis, &coeffs);
359
360        assert!(
361            is_metameric(&fibre, &target, 5e-2),
362            "fibre element must be metameric to the target"
363        );
364    }
365
366    #[test]
367    fn min_norm_spd_determinism() {
368        let target = Xyz::new(0.4, 0.7, 0.3);
369        let spd1 = min_norm_spd_for_xyz(&target);
370        let spd2 = min_norm_spd_for_xyz(&target);
371        assert_eq!(spd1, spd2, "min-norm SPD must be deterministic");
372    }
373
374    #[test]
375    fn kernel_basis_determinism() {
376        let ker_dim = SPD_SAMPLES - 3;
377        let mut b1 = vec![0.0f32; ker_dim * SPD_SAMPLES];
378        let mut b2 = vec![0.0f32; ker_dim * SPD_SAMPLES];
379        let c1 = metamer_kernel_basis(&mut b1).unwrap();
380        let c2 = metamer_kernel_basis(&mut b2).unwrap();
381        assert_eq!(c1, c2);
382        assert_eq!(b1, b2, "kernel basis must be deterministic");
383    }
384}