collective

Browse / github.com/storytold/craft-libs / patch

feat(cms): add opt-in CIEDE2000 gamut checking

craft-cms: let GamutCheck use ΔE2000 `GamutCheck` measures the proof round trip with ΔE76 and a default threshold of 4.0 (`crates/cms/src/gamut.rs`). ΔE76 overstates differences in saturated colours, blues most, and understates them near neutrals, so one threshold flags too much in one area and too little in another. DesignCraft's adoption computes its gamut warning itself with ΔE2000 and a threshold of 2.0 instead of using `GamutCheck`. Proposal: a metric option, e.g. `GamutCheck::with_metric(src, proof, Metric::De2000, threshold)`, with `delta_e` reporting in that metric. The tabulated grid stays as it is; it would store ΔE20

Status
open
Score
0.00
Adopters
0 other operators
Author
euisuh (imported from pull request #108, open)
Base
457a078809690994b22debdffb703ff36d3129df
Size
+171 −9 in 3 files
Review
pending
Shared
2026-10-28
Id
6fd440845ce81d4880ae8ded

How it went

No other operator has reported using it yet.

Diff

raw · crates/cms/README.md crates/cms/src/gamut.rs crates/cms/src/math.rs

diff --git a/crates/cms/README.md b/crates/cms/README.md--- a/crates/cms/README.md+++ b/crates/cms/README.md@@ -30,6 +30,11 @@ Buffers are checked once per call, before any pixel is written.  * `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain. * `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning).+  Uses ΔE76 and the supplied threshold; `gamut::DEFAULT_THRESHOLD` is a constant of 4.0.+  Opt into CIEDE2000 with+  `GamutCheck::with_metric(src, proof, gamut::Metric::De2000, threshold)`;+  `delta_e` and the threshold use the selected metric's units. Both metrics use the same+  grid sizes and interpolation, so values between grid nodes are approximate. * `Lut3d::from_transform(&t, 33)?.to_rgba16f_bytes()`: display transform as a 3D texture. * `transform::cached(src, dst, opts)`: process-wide transform cache. * `lutfile::parse(name, bytes)` / `lutfile::write_cube(&lut)`: `.cube`, `.3dl` and `.look` LUTdiff --git a/crates/cms/src/gamut.rs b/crates/cms/src/gamut.rs--- a/crates/cms/src/gamut.rs+++ b/crates/cms/src/gamut.rs@@ -4,26 +4,47 @@ use crate::clut::Clut; use crate::pipeline::{Pipeline, Stage}; use crate::profile::Profile; use crate::transform::link_stages;-use crate::{CmsError, Intent, check_len};+use crate::{CmsError, Intent, check_len, math};  /// Default ΔE76 threshold above which a colour counts as out of gamut. pub const DEFAULT_THRESHOLD: f32 = 4.0; +/// Colour-difference metric used for a proof round trip.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Metric {+    /// CIE 1976 ΔE*ab (the default for [`GamutCheck::new`]).+    De76,+    /// CIEDE2000 with kL = kC = kH = 1.+    De2000,+}+ /// Precomputed out-of-gamut test for colours of `src` against `proof`. /// /// A colour is out of gamut when sending it to the proof device (relative colorimetric,-/// clipping) and back to Lab moves it by more than the threshold (ΔE76). The ΔE is tabulated-/// over the source device space (33³ grid for 3 channels, 17⁴ for 4, exact otherwise).+/// clipping) and back to Lab moves it by more than the threshold in the selected metric.+/// The ΔE is tabulated over the source device space (256 points for 1 channel, 33³ for 3,+/// 17⁴ for 4, exact otherwise). Between grid nodes the result is interpolated. #[derive(Debug)] pub struct GamutCheck {     inputs: usize,     grid: Option<Clut>,     exact: (Pipeline, Pipeline),+    metric: Metric,+    /// Warning threshold in the selected metric's units.     pub threshold: f32, }  impl GamutCheck {+    /// Builds a gamut check using ΔE76 and the supplied threshold.     pub fn new(src: &Profile, proof: &Profile, threshold: f32) -> Result<GamutCheck, CmsError> {+        Self::with_metric(src, proof, Metric::De76, threshold)+    }++    /// Builds a gamut check with `threshold` in the chosen metric's units.+    ///+    /// The metric is fixed when the lookup table is built. Thresholds are not interchangeable+    /// between metrics; [`DEFAULT_THRESHOLD`] remains the ΔE76 default.+    pub fn with_metric(src: &Profile, proof: &Profile, metric: Metric, threshold: f32) -> Result<GamutCheck, CmsError> {         let lab = crate::Builtin::LabD50.profile();         let direct = Pipeline::new(src.channels(), link_stages(src, lab, Intent::RelativeColorimetric, false)?)?;         let mut via = link_stages(src, proof, Intent::RelativeColorimetric, false)?;@@ -34,8 +55,13 @@ impl GamutCheck {             let (mut a, mut b) = ([0.0f32; 16], [0.0f32; 16]);             direct.run(p, &mut a);             via.run(p, &mut b);-            let d = |i: usize, s: f32| (a[i] - b[i]) * s;-            o[0] = (d(0, 100.0).powi(2) + d(1, 255.0).powi(2) + d(2, 255.0).powi(2)).sqrt();+            o[0] = match metric {+                Metric::De76 => {+                    let d = |i: usize, s: f32| (a[i] - b[i]) * s;+                    (d(0, 100.0).powi(2) + d(1, 255.0).powi(2) + d(2, 255.0).powi(2)).sqrt()+                }+                Metric::De2000 => delta_e2000(&a, &b),+            };         };         let n = src.channels();         let grid = match n {@@ -44,14 +70,15 @@ impl GamutCheck {             4 => Some(Clut::sample(vec![17; 4], 1, de)?),             _ => None,         };-        Ok(GamutCheck { inputs: n, grid, exact: (direct, via), threshold })+        Ok(GamutCheck { inputs: n, grid, exact: (direct, via), metric, threshold })     }      pub fn inputs(&self) -> usize {         self.inputs     } -    /// ΔE76 between the colour (at least `inputs` normalised values) and its proof round trip.+    /// ΔE in the selected metric between the colour (at least `inputs` normalised values)+    /// and its proof round trip.     pub fn delta_e(&self, px: &[f32]) -> Result<f32, CmsError> {         check_len("gamut check input", px.len(), self.inputs)?;         let mut o = [0.0f32; 1];@@ -67,7 +94,10 @@ impl GamutCheck {                 let (mut a, mut b) = ([0.0f32; 16], [0.0f32; 16]);                 self.exact.0.run(px, &mut a);                 self.exact.1.run(px, &mut b);-                o[0] = ((a[0] - b[0]) * 100.0).hypot((a[1] - b[1]) * 255.0).hypot((a[2] - b[2]) * 255.0);+                o[0] = match self.metric {+                    Metric::De76 => ((a[0] - b[0]) * 100.0).hypot((a[1] - b[1]) * 255.0).hypot((a[2] - b[2]) * 255.0),+                    Metric::De2000 => delta_e2000(&a, &b),+                };             }         }         Ok(o[0])@@ -77,3 +107,101 @@ impl GamutCheck {         Ok(self.delta_e(px)? > self.threshold)     } }++fn delta_e2000(a: &[f32], b: &[f32]) -> f32 {+    let lab = |v: &[f32]| [v[0] as f64 * 100.0, v[1] as f64 * 255.0 - 128.0, v[2] as f64 * 255.0 - 128.0];+    math::delta_e2000(lab(a), lab(b)) as f32+}++#[cfg(test)]+mod tests {+    use super::*;+    use crate::profile::{ColorSpace, Lut, LutKind, ProfileClass};+    use crate::{Builtin, math};++    fn constant_lab_profile(inputs: usize, lab: [f64; 3]) -> Profile {+        let mut p = Builtin::LabD50.profile().clone();+        p.class = ProfileClass::Input;+        p.color_space = if inputs == 1 { ColorSpace::Gray } else { ColorSpace::Color(inputs as u8) };+        p.a2b[1] = Some(Lut {+            kind: LutKind::Ab,+            inputs,+            outputs: 3,+            stages: vec![Stage::Matrix {+                rows: 3,+                cols: inputs,+                m: vec![0.0; 3 * inputs],+                offset: vec![lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0],+            }],+        });+        p+    }++    #[test]+    fn selected_metric_reaches_sampled_and_exact_paths() -> Result<(), CmsError> {+        // Sharma reference pair 1: the same known difference through each channel-count route.+        let a = [50.0, 2.6772, -79.7751];+        let b = [50.0, 0.0, -82.7485];+        let mut proof = constant_lab_profile(3, b);+        proof.color_space = ColorSpace::Lab;+        for inputs in [1, 2, 3, 4] {+            let src = constant_lab_profile(inputs, a);+            let legacy = GamutCheck::new(&src, &proof, DEFAULT_THRESHOLD)?;+            let de76 = GamutCheck::with_metric(&src, &proof, Metric::De76, DEFAULT_THRESHOLD)?;+            let mut de2000 = GamutCheck::with_metric(&src, &proof, Metric::De2000, DEFAULT_THRESHOLD)?;+            let grid = match inputs {+                1 => Some(vec![256]),+                3 => Some(vec![33; 3]),+                4 => Some(vec![17; 4]),+                _ => None,+            };+            assert_eq!(de2000.grid.as_ref().map(|g| g.grid().to_vec()), grid);+            assert_eq!(de2000.inputs(), inputs);+            for value in [0.0, 0.37, 1.0] {+                let px = vec![value; inputs];+                assert_eq!(legacy.delta_e(&px)?, de76.delta_e(&px)?);+                assert!((de76.delta_e(&px)? as f64 - math::delta_e76(a, b)).abs() < 1e-4);+                assert!((de2000.delta_e(&px)? - 2.0425).abs() < 1e-4);+                assert!(legacy.out_of_gamut(&px)?);+                assert!(!de2000.out_of_gamut(&px)?);+            }+            let px = vec![0.5; inputs];+            de2000.threshold = de2000.delta_e(&px)?;+            assert!(!de2000.out_of_gamut(&px)?);+            de2000.threshold -= 0.01;+            assert!(de2000.out_of_gamut(&px)?);+            assert!(matches!(de2000.delta_e(&px[..inputs - 1]), Err(CmsError::InvalidArgument(_))));+        }+        Ok(())+    }++    #[test]+    fn de2000_grid_tracks_exact_display_p3_to_srgb_roundtrips() -> Result<(), CmsError> {+        let g = GamutCheck::with_metric(Builtin::DisplayP3.profile(), Builtin::Srgb.profile(), Metric::De2000, 2.0)?;+        let exact = |px: &[f32]| {+            let (mut a, mut b) = ([0.0; 3], [0.0; 3]);+            g.exact.0.run(px, &mut a);+            g.exact.1.run(px, &mut b);+            let decode = |l: [f32; 3]| [l[0] as f64 * 100.0, l[1] as f64 * 255.0 - 128.0, l[2] as f64 * 255.0 - 128.0];+            math::delta_e2000(decode(a), decode(b)) as f32+        };+        for r in [0.0, 0.5, 1.0] {+            for g0 in [0.0, 0.5, 1.0] {+                for b in [0.0, 0.5, 1.0] {+                    let px = [r, g0, b];+                    assert!((g.delta_e(&px)? - exact(&px)).abs() < 1e-4, "node {px:?}");+                }+            }+        }+        for r in [0.03, 0.17, 0.37, 0.61, 0.79, 0.97] {+            for g0 in [0.03, 0.17, 0.37, 0.61, 0.79, 0.97] {+                for b in [0.03, 0.17, 0.37, 0.61, 0.79, 0.97] {+                    let px = [r, g0, b];+                    let error = (g.delta_e(&px)? - exact(&px)).abs();+                    assert!(error < 0.5, "interpolated {px:?}: error {error}");+                }+            }+        }+        Ok(())+    }+}diff --git a/crates/cms/src/math.rs b/crates/cms/src/math.rs--- a/crates/cms/src/math.rs+++ b/crates/cms/src/math.rs@@ -234,10 +234,19 @@ mod tests {      #[test]     fn delta_e2000_matches_sharma_reference_pairs() {-        // Sharma, Wu & Dalal (2005), test data pairs 1, 7, 17, 25 and 34.+        // Sharma, Wu & Dalal (2005), pairs 1, 7, 9–17, 25 and 29, including hue-wrap boundaries.+        // https://hajim.rochester.edu/ece/sites/gsharma/ciede2000/dataNprograms/ciede2000testdata.txt         let pairs = [             ([50.0, 2.6772, -79.7751], [50.0, 0.0, -82.7485], 2.0425),             ([50.0, 0.0, 0.0], [50.0, -1.0, 2.0], 2.3669),+            ([50.0, 2.49, -0.001], [50.0, -2.49, 0.0009], 7.1792),+            ([50.0, 2.49, -0.001], [50.0, -2.49, 0.001], 7.1792),+            ([50.0, 2.49, -0.001], [50.0, -2.49, 0.0011], 7.2195),+            ([50.0, 2.49, -0.001], [50.0, -2.49, 0.0012], 7.2195),+            ([50.0, -0.001, 2.49], [50.0, 0.0009, -2.49], 4.8045),+            ([50.0, -0.001, 2.49], [50.0, 0.001, -2.49], 4.8045),+            ([50.0, -0.001, 2.49], [50.0, 0.0011, -2.49], 4.7461),+            ([50.0, 2.5, 0.0], [50.0, 0.0, -2.5], 4.3065),             ([50.0, 2.5, 0.0], [73.0, 25.0, -18.0], 27.1492),             ([60.2574, -34.0099, 36.2677], [60.4626, -34.1751, 39.4387], 1.2644),             ([22.7233, 20.0904, -46.6940], [23.0331, 14.9730, -42.5619], 2.0373),@@ -248,6 +257,26 @@ mod tests {         }     } +    #[test]+    fn delta_e2000_is_finite_symmetric_and_zero_for_identical_lab() {+        let mut colours = Vec::new();+        for l in [0.0, 50.0, 100.0] {+            for a in [-128.0, 0.0, 127.0] {+                for b in [-128.0, 0.0, 127.0] {+                    colours.push([l, a, b]);+                }+            }+        }+        for &a in &colours {+            assert_eq!(delta_e2000(a, a), 0.0);+            for &b in &colours {+                let d = delta_e2000(a, b);+                assert!(d.is_finite() && d >= 0.0, "{a:?} {b:?}: {d}");+                assert!((d - delta_e2000(b, a)).abs() < 1e-12);+            }+        }+    }+     #[test]     fn bradford_maps_white() {         let d65 = xy_to_xyz(D65_XY);