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
@@ -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 {@@ -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}; @@ -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)?;new file mode 100644@@ -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}");+}@@ -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]);