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 <[email protected]>
This commit is contained in:
ljkxandClaude Opus 5.5 authored and GitHub committed 2026-10-07 10:43:18 +07:00
1 parent 870741a5cd
commit d2576b27eb
4 files changed
+125 -2

No files matched your search

+3
View File
@@ -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
+42
View File
@@ -147,6 +147,11 @@ pub struct Lut {
pub stages: Vec<Stage>,
}
/// 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] {
+49
View File
@@ -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()));
}
+31 -2
View File
@@ -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);