Browse / github.com/storytold/craft-libs / patch
cms: let GamutCheck measure in ΔE2000 (#62)
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
- Hamza0101 (imported from pull request #221, open)
- Base
1f805378d454dac48b5c214029d254055fdf961b- Size
- +122 −8 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
814939f81e71059d6292cc00
How it went
No other operator has reported using it yet.
Diff
raw · crates/cms/src/gamut.rs crates/cms/src/lib.rs crates/cms/tests/cms.rs
@@ -9,21 +9,72 @@ use crate::{CmsError, Intent, check_len}; /// Default ΔE76 threshold above which a colour counts as out of gamut. pub const DEFAULT_THRESHOLD: f32 = 4.0; +/// Which colour-difference formula a gamut check measures the round trip with.+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]+pub enum Metric {+ /// ΔE*ab (CIE 1976): the Euclidean distance in Lab. It overstates differences in saturated+ /// colours, blues most, and understates them near neutrals, so one threshold flags too much+ /// in one part of the space and too little in another.+ #[default]+ De76,+ /// ΔE00 (CIEDE2000, CIE 142-2001): the weighted difference, which one threshold can be set+ /// against across the whole space. About half the ΔE76 number for the same visual difference,+ /// so the usual warning threshold is 2.0 rather than 4.0.+ De2000,+}++impl Metric {+ /// The threshold a gamut warning is usually drawn at in this metric.+ pub const fn default_threshold(self) -> f32 {+ match self {+ Metric::De76 => DEFAULT_THRESHOLD,+ Metric::De2000 => 2.0,+ }+ }+}+ /// 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. The difference is tabulated+/// over the source device space (33³ grid for 3 channels, 17⁴ for 4, exact otherwise), in the+/// [`Metric`] the check was built with. #[derive(Debug)] pub struct GamutCheck { inputs: usize, grid: Option<Clut>, exact: (Pipeline, Pipeline),+ metric: Metric, pub threshold: f32, } +/// The difference between two Lab colours in the ICC v4 encoding the pipelines here emit+/// (L/100, (a+128)/255), in `metric`.+fn difference(metric: Metric, a: &[f32], b: &[f32]) -> f32 {+ match metric {+ // The scaled differences, as before: the offsets cancel, and the result is the same+ // number this reported before there was a choice of metric, bit for bit.+ 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()+ }+ // ΔE00 weights lightness, chroma and hue, so it needs the colours themselves.+ Metric::De2000 => {+ let lab = |v: &[f32]| [f64::from(v[0]) * 100.0, f64::from(v[1]) * 255.0 - 128.0, f64::from(v[2]) * 255.0 - 128.0];+ crate::math::delta_e2000(lab(a), lab(b)) as f32+ }+ }+}+ impl GamutCheck {+ /// A check measuring in ΔE76, as this has always done. pub fn new(src: &Profile, proof: &Profile, threshold: f32) -> Result<GamutCheck, CmsError> {+ GamutCheck::with_metric(src, proof, Metric::De76, threshold)+ }++ /// A check measuring in `metric`. [`Metric::default_threshold`] gives the threshold that+ /// metric is usually read against.+ 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 +85,7 @@ 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] = difference(metric, &a, &b); }; let n = src.channels(); let grid = match n {@@ -44,14 +94,20 @@ 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.+ /// The metric this check measures in.+ pub fn metric(&self) -> Metric {+ self.metric+ }++ /// The difference between the colour (at least `inputs` normalised values) and its proof+ /// round trip, in [`GamutCheck::metric`]. 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 +123,7 @@ 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] = difference(self.metric, &a, &b); } } Ok(o[0])@@ -32,7 +32,7 @@ mod write; pub use builtin::Builtin; pub use clut::Clut; pub use curve::Curve;-pub use gamut::GamutCheck;+pub use gamut::{GamutCheck, Metric}; pub use lut3d::Lut3d; pub use profile::{ColorSpace, Pcs, Profile, ProfileClass}; pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached};@@ -991,3 +991,61 @@ fn white_is_exact_on_every_path() { assert!(o[2] < 0.99, "{cmyk:?}: absolute paper {o:?}"); } }++/// `GamutCheck` measures in either metric (#62). ΔE76 overstates saturated colours — sRGB blue+/// against the coated CMYK proof is 79.6 in ΔE76 and 15.0 in ΔE00 — and understates them near+/// neutrals, so at the thresholds each metric is read against (4.0 and 2.0) the two disagree in+/// both directions. DesignCraft computed its own warning in ΔE00 for that reason.+#[test]+fn gamut_check_measures_in_either_metric() {+ use craft_cms::{GamutCheck, Metric};++ assert_eq!(Metric::default(), Metric::De76, "the metric this has always used");+ assert_eq!(Metric::De76.default_threshold(), 4.0);+ assert_eq!(Metric::De2000.default_threshold(), 2.0);++ let de76 = GamutCheck::with_metric(srgb(), cmyk(), Metric::De76, Metric::De76.default_threshold()).unwrap();+ let de00 = GamutCheck::with_metric(srgb(), cmyk(), Metric::De2000, Metric::De2000.default_threshold()).unwrap();+ assert_eq!(de76.metric(), Metric::De76);+ assert_eq!(de00.metric(), Metric::De2000);++ // `new` is the ΔE76 check it always was.+ let old = GamutCheck::new(srgb(), cmyk(), 4.0).unwrap();+ assert_eq!(old.metric(), Metric::De76);+ for px in [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.5, 0.5, 0.5]] {+ assert_eq!(old.delta_e(&px).unwrap(), de76.delta_e(&px).unwrap(), "{px:?}");+ }++ // Saturated blue, the colour ΔE76 overstates most: both call it out of gamut, by numbers that+ // are five times apart.+ let blue = [0.0, 0.0, 1.0];+ let (a, b) = (de76.delta_e(&blue).unwrap(), de00.delta_e(&blue).unwrap());+ assert!((70.0..90.0).contains(&a), "blue in dE76: {a}");+ assert!((10.0..20.0).contains(&b), "blue in dE2000: {b}");+ assert!(a / b > 4.0, "dE76 overstates blue: {a} / {b}");+ assert!(de76.out_of_gamut(&blue).unwrap() && de00.out_of_gamut(&blue).unwrap());++ // A dark near-neutral olive, where ΔE00 is the larger of the two.+ let olive = [0.3125, 0.25, 0.1875];+ let (a, b) = (de76.delta_e(&olive).unwrap(), de00.delta_e(&olive).unwrap());+ assert!(b > a, "dE2000 understated near the neutral axis: {a} / {b}");++ // Over the cube, each metric flags colours the other does not, at its own threshold: the one+ // threshold ΔE76 is given cannot hold for both ends of the space.+ let n = 17;+ let (mut only76, mut only00) = (0, 0);+ for i in 0..n {+ for j in 0..n {+ for k in 0..n {+ let d = |v: usize| v as f32 / (n - 1) as f32;+ let px = [d(i), d(j), d(k)];+ match (de76.out_of_gamut(&px).unwrap(), de00.out_of_gamut(&px).unwrap()) {+ (true, false) => only76 += 1,+ (false, true) => only00 += 1,+ _ => {}+ }+ }+ }+ }+ assert!(only76 > 0 && only00 > 0, "dE76 only {only76}, dE2000 only {only00} of {}", n * n * n);+}