From d2576b27ebecf84abaa313f8caf25179529a1cb2 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 7 Oct 2026 05:43:18 +0200 Subject: [PATCH] Profile mismatch: compare profiles by colour, not bytes (#394) Opening any file Photoshop tagged with sRGB IEC61966-2.1 asked "Embedded Profile Mismatch: sRGB IEC61966-2.1 vs sRGB IEC61966-2.1": open_policy compared a hash of the ICC bytes, and Photoshop's v2 profile (1024-entry tables) and the built-in v4 sRGB (parametric curves) are the same colours in different bytes. 32-bit documents in Photoshop's linear sRGB missed the stay-linear exception for the same reason. Add Profile::same_colors: same colour space and every sample of a device grid within dE76 0.5 through both (re-encoding costs 0.025; moving the red primary 2% costs 1.0). Colours are compared rather than round-tripped because CMYK -> PCS -> CMYK is not an identity even for one profile. Of the 75 profile-tagged PSDs in the test corpora, all 75 prompted on open; now 6 do (Adobe RGB, Gray Gamma 2.2, SWOP), each a real mismatch. Co-authored-by: Claude Opus 5.5 --- crates/cms/README.md | 3 ++ crates/cms/src/profile.rs | 42 ++++++++++++++++++++++++++++ crates/cms/tests/cms.rs | 49 +++++++++++++++++++++++++++++++++ crates/engine/src/color_cmds.rs | 33 ++++++++++++++++++++-- 4 files changed, 125 insertions(+), 2 deletions(-) diff --git a/crates/cms/README.md b/crates/cms/README.md index 815ca1831..022d9df95 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -22,6 +22,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou * `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture. * `transform::cached(src, dst, opts)`: process-wide transform cache. * `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding. +* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of + a device grid within ΔE 0.5 through both), e.g. Photoshop's sRGB IEC61966-2.1 and the built-in + sRGB. `content_hash()` compares bytes and suits cache keys only. ### Evaluation diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index 03af210ef..93719f2a7 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -147,6 +147,11 @@ pub struct Lut { pub stages: Vec, } +/// Largest colour difference (CIE ΔE*ab) for which [`Profile::same_colors`] treats two profiles +/// as one colour space: invisible, yet 20× what re-encoding a profile costs (sRGB as v2 tables +/// against v4 parametric curves differs by 0.025). Moving sRGB's red primary 2% costs 1.0. +pub const SAME_COLORS_MAX_DELTA_E: f64 = 0.5; + /// A parsed ICC profile. #[derive(Clone, Debug, PartialEq)] pub struct Profile { @@ -216,6 +221,43 @@ impl Profile { }) } + /// Whether `self` and `other` describe the same colours: same colour space, and every sample + /// of a device-value grid has the same colour through both (relative colorimetric, within + /// [`SAME_COLORS_MAX_DELTA_E`]). Different encodings of one space are the same colours: the + /// sRGB IEC61966-2.1 Photoshop embeds (v2, 1024-entry tables) and the built-in sRGB (v4, + /// parametric curves) differ in every byte. Colours are compared rather than round-tripped + /// because CMYK → PCS → CMYK is not an identity even through a single profile. + pub fn same_colors(&self, other: &Profile) -> bool { + if self.content_hash() == other.content_hash() { + return true; + } + let n = self.channels(); + if self.color_space != other.color_space || n == 0 || n > 4 { + return false; + } + let lab = crate::builtin::Builtin::LabD50.profile(); + let to_lab = |p: &Profile| crate::transform::Transform::new(p, lab, Intent::RelativeColorimetric, false); + let (Ok(a), Ok(b)) = (to_lab(self), to_lab(other)) else { + return false; + }; + // 17 steps per channel catch a TRC that differs only in its toe; 9 keep CMYK at 6561 samples. + let steps: usize = if n <= 3 { 17 } else { 9 }; + // Lab D50 is the PCS, normalised as in ICC v4. + let decode = |o: [f32; 3]| [f64::from(o[0]) * 100.0, f64::from(o[1]) * 255.0 - 128.0, f64::from(o[2]) * 255.0 - 128.0]; + let mut input = [0f32; 4]; + (0..steps.pow(n as u32)).all(|i| { + let mut k = i; + for v in input.iter_mut().take(n) { + *v = (k % steps) as f32 / (steps - 1) as f32; + k /= steps; + } + let (mut la, mut lb) = ([0f32; 3], [0f32; 3]); + a.eval(&input[..n], &mut la); + b.eval(&input[..n], &mut lb); + math::delta_e76(decode(la), decode(lb)) <= SAME_COLORS_MAX_DELTA_E + }) + } + /// Media white point used for ICC-absolute colorimetry: `wtpt`, except for v2 display /// profiles whose `wtpt` records the (unadapted) display white; those are treated as D50. pub fn media_white(&self) -> [f64; 3] { diff --git a/crates/cms/tests/cms.rs b/crates/cms/tests/cms.rs index 32470736a..81f9d0b67 100644 --- a/crates/cms/tests/cms.rs +++ b/crates/cms/tests/cms.rs @@ -590,3 +590,52 @@ fn gray_profile_with_reversed_lut_type_reencodes() { odd.description = "re-encoded".into(); let _ = Profile::parse(&odd.with_encoded_bytes().to_bytes()).unwrap(); } + +/// sRGB the way Photoshop embeds it: a v2 profile whose TRCs are 1024-entry 16-bit tables. +fn srgb_as_v2_tables() -> Profile { + let mut p = srgb().clone(); + let table = |c: &photocraft_cms::Curve| { + photocraft_cms::Curve::Table((0..1024).map(|i| ((c.eval64(i as f64 / 1023.0) * 65535.0).round() / 65535.0) as f32).collect()) + }; + let trc = p.trc.clone().unwrap(); + p.trc = Some([table(&trc[0]), table(&trc[1]), table(&trc[2])]); + p.version = (2, 0x10); + p.with_encoded_bytes() +} + +#[test] +fn same_colors_ignores_the_encoding() { + let v2 = Profile::parse(&srgb_as_v2_tables().to_bytes()).unwrap(); + assert_ne!(v2.content_hash(), srgb().content_hash(), "different bytes"); + assert!(v2.same_colors(srgb()) && srgb().same_colors(&v2)); + for b in Builtin::ALL { + assert!(b.profile().same_colors(b.profile()), "{b:?}"); + // Re-encoded (fresh bytes, same model): CMYK too, where a round trip is not an identity. + let again = Profile::parse(&b.profile().clone().with_encoded_bytes().to_bytes()).unwrap(); + assert!(again.same_colors(b.profile()), "{b:?} re-encoded"); + } +} + +#[test] +fn same_colors_tells_different_spaces_apart() { + for other in [Builtin::DisplayP3, Builtin::LinearSrgb, Builtin::AdobeRgbCompat, Builtin::Rec2020, Builtin::CoatedCmyk, Builtin::GrayGamma22] { + assert!(!srgb().same_colors(other.profile()), "{other:?}"); + } + assert!(!Builtin::GrayGamma22.profile().same_colors(Builtin::SGray.profile())); + // Scaling the red primary's XYZ by 2% makes a different space too. + let mut moved = srgb().clone(); + let m = moved.matrix.as_mut().unwrap(); + for row in m.iter_mut() { + row[0] *= 1.02; + } + assert!(!moved.with_encoded_bytes().same_colors(srgb())); +} + +/// The sRGB IEC61966-2.1 profile Windows ships (the one Photoshop embeds), when present. +#[test] +fn windows_srgb_is_the_builtin_srgb() { + let Ok(bytes) = std::fs::read("C:/Windows/System32/spool/drivers/color/sRGB Color Space Profile.icm") else { return }; + let p = Profile::parse(&bytes).unwrap(); + assert_ne!(p.content_hash(), srgb().content_hash()); + assert!(p.same_colors(srgb())); +} diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index a0a4245de..555ca296b 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -243,11 +243,12 @@ impl ColorState { let embedded = doc.icc_profile.as_ref().and_then(|b| profile_from_bytes(b).ok()).filter(|p| p.color_space == space); match embedded { Some(emb) => { - let mismatch = emb.content_hash() != working.content_hash(); + // Compared by colour, not bytes: Photoshop's sRGB IEC61966-2.1 is our working sRGB. + let mismatch = !emb.same_colors(&working); let base = json!({"embedded": emb.description, "working": working.description, "mismatch": mismatch, "policy": policy.id()}); // 32-bit linear images (EXR/HDR are tagged linear sRGB on import) stay linear, as // Photoshop keeps 32-bit documents in a linear version of the working space. - let linear_hdr = doc.depth == SampleType::F32 && emb.content_hash() == Builtin::LinearSrgb.profile().content_hash(); + let linear_hdr = doc.depth == SampleType::F32 && emb.same_colors(Builtin::LinearSrgb.profile()); if !mismatch || linear_hdr { return merge(base, json!({"action": "kept"})); } @@ -1234,6 +1235,34 @@ mod settings_tests { // Matching profiles never ask. let (_, r) = s.open_document(tagged("srgb"), None); assert_eq!(r["mismatch"], false); + // Nor does the same space in other bytes: sRGB as Photoshop embeds it (v2, 1024-entry + // tables) is the working sRGB, even under Convert, and the file keeps its own profile. + s.execute("edit.colorSettings", json!({"policyRgb": "convert"})).unwrap(); + let mut v2 = Builtin::Srgb.profile().clone(); + let trc = v2.trc.clone().unwrap(); + let table = |c: &photocraft_cms::Curve| photocraft_cms::Curve::Table((0..1024).map(|i| c.eval64(f64::from(i) / 1023.0) as f32).collect()); + v2.trc = Some([table(&trc[0]), table(&trc[1]), table(&trc[2])]); + v2.version = (2, 0x10); + let bytes = v2.with_encoded_bytes().to_bytes(); + assert_ne!(bytes, Builtin::Srgb.profile().to_bytes()); + let mut d = tagged("srgb"); + d.icc_profile = Some(bytes.clone()); + let (_, r) = s.open_document(d, None); + assert_eq!((r["action"].as_str(), r["mismatch"].as_bool(), r.get("ask")), (Some("kept"), Some(false), None)); + assert_eq!(s.active().unwrap().doc.icc_profile.as_ref(), Some(&bytes)); + // A 32-bit document in Photoshop's linear sRGB (a v2 profile recording the D65 display + // white) stays linear without asking, like one tagged with our own linear sRGB. + let mut lin = Builtin::LinearSrgb.profile().clone(); + lin.version = (2, 0x10); + lin.white_point = [0.95047, 1.0, 1.08905]; + let bytes = lin.with_encoded_bytes().to_bytes(); + assert_ne!(bytes, Builtin::LinearSrgb.profile().to_bytes()); + let mut d = Document::with_background("t", photocraft_doc::Size::new(8, 8), ColorMode::Rgb, SampleType::F32, Color::rgba(0.2, 0.6, 0.9, 1.0)); + d.icc_profile = Some(bytes.clone()); + let (_, r) = s.open_document(d, None); + assert_eq!((r["action"].as_str(), r.get("ask")), (Some("kept"), None)); + assert_eq!(s.active().unwrap().doc.icc_profile.as_ref(), Some(&bytes)); + s.execute("edit.colorSettings", json!({"policyRgb": "preserve"})).unwrap(); // Convert to working: pixels converted and the document tagged with sRGB. s.execute("edit.colorSettings", json!({"policyRgb": "convert"})).unwrap(); let (_, r) = s.open_document(tagged("adobe-rgb-compat"), None);