collective

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

craft-cms: public constructors for matrix/TRC and gray profiles (closes #61)

craft-cms: public constructors for matrix/TRC and grey profiles Code outside craft-cms can get a `Profile` only from `Profile::parse`, `Builtin`, or the CMYK functions in `synth`. `Profile` has public fields, but `bytes` and `hash` are `pub(crate)`, so other crates can't build one with a struct literal. Profiles built from parameters are needed outside the crate: - the monitor-profile crate planned as #36 step 2 (primaries, white point and gamma from the display); - PDF `CalRGB`/`CalGray` colour spaces in the apps that read PDF. Proposal: `Profile::matrix_shaper(white, matrix, [Curve; 3])` and `Profile::gray(white, Curve)`, returning `Result<Profile, Cms

Status
open
Score
0.00
Adopters
0 other operators
Author
zachshallbetter (imported from pull request #87, open)
Base
e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e
Size
+288 −3 in 5 files
Review
pending
Shared
2026-10-28
Id
205d726abae95ce3238c7538

How it went

No other operator has reported using it yet.

Diff

raw · crates/cms/src/curve.rs crates/cms/src/lib.rs crates/cms/src/profile.rs crates/cms/tests/constructors.rs crates/cms/tests/fogra39.rs

diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs--- a/crates/cms/src/curve.rs+++ b/crates/cms/src/curve.rs@@ -26,6 +26,37 @@ pub fn srgb_trc() -> Curve { }  impl Curve {+    /// Creates a gamma curve `y = x^g`. Returns `Identity` if `g == 1.0` (within 1e-6).+    pub fn gamma(g: f64) -> Result<Self, crate::CmsError> {+        if !g.is_finite() || g <= 0.0 {+            return Err(crate::CmsError::InvalidArgument("gamma must be positive and finite".into()));+        }+        if (g - 1.0).abs() < 1e-6 { Ok(Curve::Identity) } else { Ok(Curve::Gamma(g)) }+    }++    /// Creates an ICC parametric curve (types 0 to 4).+    pub fn parametric(kind: u16, p: [f64; 7]) -> Result<Self, crate::CmsError> {+        if kind > 4 {+            return Err(crate::CmsError::InvalidArgument(format!("parametric curve kind {kind} must be <= 4")));+        }+        let count = Self::param_count(kind);+        if !p.iter().take(count).all(|v| v.is_finite()) {+            return Err(crate::CmsError::InvalidArgument("parametric curve parameters must be finite".into()));+        }+        Ok(Curve::Parametric { kind, p })+    }++    /// Creates a sampled lookup table curve over evenly spaced inputs in `[0, 1]`.+    pub fn from_table(samples: Vec<f32>) -> Result<Self, crate::CmsError> {+        if samples.len() < 2 {+            return Err(crate::CmsError::InvalidArgument("sampled curve must contain at least 2 entries".into()));+        }+        if !samples.iter().all(|s| s.is_finite() && (0.0..=1.0).contains(s)) {+            return Err(crate::CmsError::InvalidArgument("sampled curve values must be finite and within [0, 1]".into()));+        }+        Ok(Curve::Table(samples))+    }+     /// Number of parameters stored for a parametric function type.     pub fn param_count(kind: u16) -> usize {         match kind {diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs--- a/crates/cms/src/lib.rs+++ b/crates/cms/src/lib.rs@@ -13,7 +13,7 @@ //! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas. //! //! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications,-//! CIE 15, and Adobe's published black point compensation paper.+//! CIE 15, and the ISO 18619 black point compensation standard. #![forbid(unsafe_code)]  pub mod builtin;@@ -34,6 +34,7 @@ pub use clut::Clut; pub use curve::Curve; pub use gamut::GamutCheck; pub use lut3d::Lut3d;+pub use math::Mat3; pub use profile::{ColorSpace, Pcs, Profile, ProfileClass}; pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached}; diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs--- a/crates/cms/src/profile.rs+++ b/crates/cms/src/profile.rs@@ -182,6 +182,119 @@ pub struct Profile { }  impl Profile {+    /// Builds a matrix/TRC display profile from an already PCS D50-adapted colorant matrix,+    /// a white point, and per-channel tone reproduction curves.+    ///+    /// # Matrix Coordinate Space+    /// In accordance with ICC.1:2010 §9.2.35–37, the colorant matrix tags (`rXYZ`, `gXYZ`, `bXYZ`)+    /// stored in an ICC profile must be chromatically adapted to the D50 Profile Connection Space (PCS).+    /// `pcs_matrix` must therefore be expressed relative to D50 (e.g. as read directly from ICC profile tags).+    ///+    /// If `white` is not D50, the profile's `chad` tag is populated with the Bradford chromatic adaptation+    /// matrix mapping `white` to D50.+    pub fn matrix_shaper(white: [f64; 3], pcs_matrix: Mat3, trc: [Curve; 3]) -> Result<Profile, CmsError> {+        if !white.iter().all(|&c| c.is_finite() && c > 0.0) {+            return Err(CmsError::InvalidArgument("white point must be positive and finite".into()));+        }+        if !pcs_matrix.iter().flatten().all(|&v| v.is_finite()) {+            return Err(CmsError::InvalidArgument("matrix elements must be finite".into()));+        }+        if math::invert(&pcs_matrix).is_none() {+            return Err(CmsError::InvalidArgument("colorant matrix must be invertible".into()));+        }++        let d50 = math::d50_quantized();+        let is_d50 = (white[0] - math::D50[0]).abs() < 1e-4 && (white[1] - math::D50[1]).abs() < 1e-4 && (white[2] - math::D50[2]).abs() < 1e-4;++        let chad = if is_d50 { None } else { Some(math::bradford(white, math::D50)) };++        // Quantize and normalize to ensure (1, 1, 1) maps exactly to PCS D50+        let mut q = pcs_matrix.map(|row| row.map(math::s15f16_round));+        for (i, row) in q.iter_mut().enumerate() {+            let err = d50[i] - row.iter().sum::<f64>();+            let k = (0..3).max_by(|a, b| row[*a].abs().total_cmp(&row[*b].abs())).unwrap_or(0);+            row[k] = math::s15f16_round(row[k] + err);+        }+        if math::invert(&q).is_none() {+            return Err(CmsError::InvalidArgument("colorant matrix became singular after quantization/normalization".into()));+        }+        let chad_quantized = chad.map(|a| a.map(|row| row.map(math::s15f16_round)));++        Ok(Profile {+            version: (4, 0x30),+            class: ProfileClass::Display,+            color_space: ColorSpace::Rgb,+            pcs: Pcs::Xyz,+            rendering_intent: Intent::RelativeColorimetric,+            description: "Parametric Matrix/TRC".into(),+            copyright: "".into(),+            white_point: if is_d50 { d50 } else { white },+            black_point: None,+            chad: chad_quantized,+            matrix: Some(q),+            trc: Some(trc),+            gray_trc: None,+            a2b: [None, None, None],+            b2a: [None, None, None],+            bytes: None,+            hash: Default::default(),+        })+    }++    /// Builds a matrix/TRC display profile from unadapted source primaries relative to `white`.+    ///+    /// The unadapted `source_matrix` (whose columns sum to `white`) is chromatically adapted to PCS D50+    /// using the Bradford transform before storage in the profile's matrix tags.+    pub fn from_source_matrix(white: [f64; 3], source_matrix: Mat3, trc: [Curve; 3]) -> Result<Profile, CmsError> {+        if !white.iter().all(|&c| c.is_finite() && c > 0.0) {+            return Err(CmsError::InvalidArgument("white point must be positive and finite".into()));+        }+        if !source_matrix.iter().flatten().all(|&v| v.is_finite()) {+            return Err(CmsError::InvalidArgument("matrix elements must be finite".into()));+        }+        if math::invert(&source_matrix).is_none() {+            return Err(CmsError::InvalidArgument("colorant matrix must be invertible".into()));+        }++        let is_d50 = (white[0] - math::D50[0]).abs() < 1e-4 && (white[1] - math::D50[1]).abs() < 1e-4 && (white[2] - math::D50[2]).abs() < 1e-4;+        let pcs_matrix = if is_d50 {+            source_matrix+        } else {+            let adapt = math::bradford(white, math::D50);+            math::mul(&adapt, &source_matrix)+        };++        Self::matrix_shaper(white, pcs_matrix, trc)+    }++    /// Builds a monochrome profile from a white point and tone reproduction curve.+    pub fn gray(white: [f64; 3], trc: Curve) -> Result<Profile, CmsError> {+        if !white.iter().all(|&c| c.is_finite() && c > 0.0) {+            return Err(CmsError::InvalidArgument("white point must be positive and finite".into()));+        }+        let is_d50 = (white[0] - math::D50[0]).abs() < 1e-4 && (white[1] - math::D50[1]).abs() < 1e-4 && (white[2] - math::D50[2]).abs() < 1e-4;++        Ok(Profile {+            version: (4, 0x30),+            class: ProfileClass::Display,+            color_space: ColorSpace::Gray,+            pcs: Pcs::Xyz,+            rendering_intent: Intent::RelativeColorimetric,+            description: "Parametric Gray".into(),+            copyright: "".into(),+            white_point: if is_d50 { math::d50_quantized() } else { white },+            black_point: None,+            chad: None,+            matrix: None,+            trc: None,+            gray_trc: Some(trc),+            a2b: [None, None, None],+            b2a: [None, None, None],+            bytes: None,+            hash: Default::default(),+        })+    }+     /// Parses ICC profile bytes.     pub fn parse(bytes: &[u8]) -> Result<Profile, CmsError> {         let mut p = parse_profile(bytes)?;diff --git a/crates/cms/tests/constructors.rs b/crates/cms/tests/constructors.rsnew file mode 100644--- /dev/null+++ b/crates/cms/tests/constructors.rs@@ -0,0 +1,135 @@+use craft_cms::{Builtin, Curve, Intent, Mat3, Profile, Transform};++#[test]+fn test_curve_constructors() {+    // Gamma+    let g22 = Curve::gamma(2.2).expect("valid gamma");+    assert_eq!(g22, Curve::Gamma(2.2));++    let g10 = Curve::gamma(1.0).expect("gamma 1.0 is identity");+    assert_eq!(g10, Curve::Identity);++    let g_near = Curve::gamma(1.0 + 1e-7).expect("near-identity gamma normalized");+    assert_eq!(g_near, Curve::Identity);++    assert!(Curve::gamma(0.0).is_err());+    assert!(Curve::gamma(-2.2).is_err());+    assert!(Curve::gamma(f64::NAN).is_err());+    assert!(Curve::gamma(f64::INFINITY).is_err());++    // Parametric+    let p0 = Curve::parametric(0, [2.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]).expect("valid parametric type 0");+    assert_eq!(p0, Curve::Parametric { kind: 0, p: [2.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] });++    assert!(Curve::parametric(5, [0.0; 7]).is_err());+    assert!(Curve::parametric(0, [f64::NAN, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]).is_err());++    // Sampled table+    let table = Curve::from_table(vec![0.0, 0.5, 1.0]).expect("valid sampled table");+    assert_eq!(table, Curve::Table(vec![0.0, 0.5, 1.0]));++    assert!(Curve::from_table(vec![0.5]).is_err());+    assert!(Curve::from_table(vec![0.0, f32::NAN]).is_err());+    assert!(Curve::from_table(vec![0.0, -0.1, 1.0]).is_err());+    assert!(Curve::from_table(vec![0.0, 2.0]).is_err());+    assert!(Curve::from_table(vec![0.0, 1.0001]).is_err());+}++#[test]+fn test_profile_constructors() {+    let srgb = Builtin::Srgb.profile();+    let white_d65 = [0.95047, 1.0, 1.08883];++    // 1. Matrix shaper with already PCS D50-adapted matrix (e.g. read from ICC tags)+    let pcs_d50_matrix: Mat3 = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]];+    let trc = [craft_cms::curve::srgb_trc(), craft_cms::curve::srgb_trc(), craft_cms::curve::srgb_trc()];+    let custom_from_pcs = Profile::matrix_shaper(white_d65, pcs_d50_matrix, trc.clone()).expect("matrix shaper from PCS D50");+    assert_eq!(custom_from_pcs.color_space, craft_cms::ColorSpace::Rgb);+    assert!(custom_from_pcs.same_colors(srgb), "custom profile from PCS D50 matrix should match reference sRGB");++    // 2. Matrix shaper from unadapted D65 source primaries (undergoes single Bradford adaptation)+    let source_d65_matrix: Mat3 = [[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]];+    let custom_from_source = Profile::from_source_matrix(white_d65, source_d65_matrix, trc).expect("matrix shaper from source");+    assert!(custom_from_source.same_colors(srgb), "custom profile adapted from source primaries should match reference sRGB");++    // Evaluate transform+    let xform = Transform::new(&custom_from_pcs, srgb, Intent::RelativeColorimetric, true).expect("transform from custom RGB to sRGB");+    let mut out = [0.0f32; 3];+    xform.eval(&[0.5, 0.5, 0.5], &mut out).expect("eval");+    assert!((out[0] - 0.5).abs() < 1e-2);+    assert!((out[1] - 0.5).abs() < 1e-2);+    assert!((out[2] - 0.5).abs() < 1e-2);++    // Invalid matrix shaper inputs+    assert!(Profile::matrix_shaper([0.0, 1.0, 1.0], pcs_d50_matrix, [Curve::Identity, Curve::Identity, Curve::Identity]).is_err());+    assert!(Profile::matrix_shaper([f64::NAN, 1.0, 1.0], pcs_d50_matrix, [Curve::Identity, Curve::Identity, Curve::Identity]).is_err());+    let singular: Mat3 = [[0.0; 3]; 3];+    assert!(Profile::matrix_shaper(white_d65, singular, [Curve::Identity, Curve::Identity, Curve::Identity]).is_err());++    // Near-degenerate matrix that is invertible before quantization but becomes singular after+    let d50 = [0.9642, 1.0, 0.8249];+    let near_degenerate: Mat3 = [[d50[0] - 0.000001, 0.000001, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, d50[2]]];+    assert!(Profile::matrix_shaper(d50, near_degenerate, [Curve::Identity, Curve::Identity, Curve::Identity]).is_err());++    // Gray profile+    let gray = Profile::gray(white_d65, Curve::gamma(2.2).expect("curve")).expect("gray profile");+    assert_eq!(gray.color_space, craft_cms::ColorSpace::Gray);++    let gray_to_srgb = Transform::new(&gray, srgb, Intent::RelativeColorimetric, true).expect("transform from gray to sRGB");+    let mut srgb_out = [0.0f32; 3];+    gray_to_srgb.eval(&[0.5], &mut srgb_out).expect("eval");+    assert!((srgb_out[0] - srgb_out[1]).abs() < 1e-3);+    assert!((srgb_out[1] - srgb_out[2]).abs() < 1e-3);+}++#[test]+fn test_matrix_constructors_against_independent_moxcms_oracle() {+    use moxcms::{ColorProfile, Layout, TransformOptions as MoxOpts};++    let srgb = Builtin::Srgb.profile();+    let white_d65 = [0.95047, 1.0, 1.08883];++    // 1. PCS D50-adapted matrix profile+    let pcs_d50_matrix: Mat3 = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]];+    let trc = [craft_cms::curve::srgb_trc(), craft_cms::curve::srgb_trc(), craft_cms::curve::srgb_trc()];+    let custom_from_pcs = Profile::matrix_shaper(white_d65, pcs_d50_matrix, trc.clone()).expect("matrix shaper from PCS D50");++    // 2. Unadapted source matrix profile+    let source_d65_matrix: Mat3 = [[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]];+    let custom_from_source = Profile::from_source_matrix(white_d65, source_d65_matrix, trc).expect("matrix shaper from source");++    // Convert both to standard ICC bytes and parse with independent oracle moxcms+    let mox_from_pcs = ColorProfile::new_from_slice(&custom_from_pcs.to_bytes()).expect("moxcms parse custom PCS profile");+    let mox_from_source = ColorProfile::new_from_slice(&custom_from_source.to_bytes()).expect("moxcms parse custom source profile");+    let mox_dst_srgb = ColorProfile::new_from_slice(&srgb.to_bytes()).expect("moxcms parse builtin sRGB");++    let test_pixels: Vec<u8> = (0..512u32).flat_map(|i| [(i * 17 % 256) as u8, (i * 53 % 256) as u8, (i * 97 % 256) as u8]).collect();++    // Verify 1: moxcms transforming custom_from_pcs -> builtin sRGB produces identity (diff <= 1)+    let mox_xform_pcs = mox_from_pcs.create_transform_8bit(Layout::Rgb, &mox_dst_srgb, Layout::Rgb, MoxOpts::default()).unwrap();+    let mut mox_out_pcs = vec![0u8; test_pixels.len()];+    mox_xform_pcs.transform(&test_pixels, &mut mox_out_pcs).unwrap();+    let max_diff_pcs = test_pixels.iter().zip(&mox_out_pcs).map(|(a, b)| (*a as i32 - *b as i32).abs()).max().unwrap();+    assert!(max_diff_pcs <= 1, "moxcms PCS-adapted profile -> sRGB max diff was {max_diff_pcs}");++    // Verify 2: moxcms transforming custom_from_source -> builtin sRGB produces identity (diff <= 1)+    let mox_xform_src = mox_from_source.create_transform_8bit(Layout::Rgb, &mox_dst_srgb, Layout::Rgb, MoxOpts::default()).unwrap();+    let mut mox_out_src = vec![0u8; test_pixels.len()];+    mox_xform_src.transform(&test_pixels, &mut mox_out_src).unwrap();+    let max_diff_src = test_pixels.iter().zip(&mox_out_src).map(|(a, b)| (*a as i32 - *b as i32).abs()).max().unwrap();+    assert!(max_diff_src <= 1, "moxcms source-adapted profile -> sRGB max diff was {max_diff_src}");++    // Verify 3: craft_cms and moxcms agree when converting from custom_from_source to DisplayP3+    let p3 = Builtin::DisplayP3.profile();+    let mox_dst_p3 = ColorProfile::new_from_slice(&p3.to_bytes()).expect("moxcms parse Display P3");+    let mox_xform_p3 = mox_from_source.create_transform_8bit(Layout::Rgb, &mox_dst_p3, Layout::Rgb, MoxOpts::default()).unwrap();+    let mut mox_p3_out = vec![0u8; test_pixels.len()];+    mox_xform_p3.transform(&test_pixels, &mut mox_p3_out).unwrap();++    let ours_xform_p3 = Transform::new(&custom_from_source, p3, Intent::RelativeColorimetric, false).unwrap();+    let mut ours_p3_out = vec![0u8; test_pixels.len()];+    ours_xform_p3.convert_u8(&test_pixels, 3, &mut ours_p3_out, 3, false).unwrap();++    let max_p3_diff = ours_p3_out.iter().zip(&mox_p3_out).map(|(a, b)| (*a as i32 - *b as i32).abs()).max().unwrap();+    assert!(max_p3_diff <= 1, "craft_cms vs moxcms custom source profile -> P3 max diff was {max_p3_diff}");+}diff --git a/crates/cms/tests/fogra39.rs b/crates/cms/tests/fogra39.rs--- a/crates/cms/tests/fogra39.rs+++ b/crates/cms/tests/fogra39.rs@@ -545,8 +545,13 @@ fn stand_in_separations_respect_the_ink_limit() {             let t = Transform::new(Builtin::Srgb.profile(), cmyk, intent, true).unwrap();             let mut out = vec![0.0f32; N * N * N * 4];             t.convert_f32(&grid, 3, &mut out, 4, false).unwrap();-            let (i, max) =-                out.chunks_exact(4).map(|c| c.iter().sum::<f32>()).enumerate().fold((0, 0.0f32), |m, (i, s)| if s > m.1 { (i, s) } else { m });+            let (i, max) = out+                .as_chunks::<4>()+                .0+                .iter()+                .map(|c| c.iter().sum::<f32>())+                .enumerate()+                .fold((0, 0.0f32), |m, (i, s)| if s > m.1 { (i, s) } else { m });             eprintln!("{name}, {intent:?}: max total ink {:.2} % at sRGB {:?}", max * 100.0, &grid[i * 3..i * 3 + 3]);             assert!(max <= LIMIT, "{name}, {intent:?}: total ink {max} at sRGB {:?}", &grid[i * 3..i * 3 + 3]);