510 lines
24 KiB
Rust
510 lines
24 KiB
Rust
//! Integration tests: built-in profiles, known colorimetric values, round trips, CMYK
|
||
//! behaviour, intents, BPC, interpolation accuracy, real-world profiles and an independent
|
||
//! oracle (moxcms, dev-dependency only).
|
||
|
||
use photocraft_cms::math::{self, delta_e76};
|
||
use photocraft_cms::{Builtin, Clut, ColorSpace, Intent, Lut3d, Profile, Transform, TransformOptions};
|
||
|
||
fn lab() -> &'static Profile {
|
||
Builtin::LabD50.profile()
|
||
}
|
||
fn srgb() -> &'static Profile {
|
||
Builtin::Srgb.profile()
|
||
}
|
||
fn cmyk() -> &'static Profile {
|
||
Builtin::CoatedCmyk.profile()
|
||
}
|
||
|
||
/// Normalised device Lab (v4 encoding) → float Lab.
|
||
fn dec(o: &[f32]) -> [f64; 3] {
|
||
[o[0] as f64 * 100.0, o[1] as f64 * 255.0 - 128.0, o[2] as f64 * 255.0 - 128.0]
|
||
}
|
||
fn enc(l: [f64; 3]) -> [f32; 3] {
|
||
[(l[0] / 100.0) as f32, ((l[1] + 128.0) / 255.0) as f32, ((l[2] + 128.0) / 255.0) as f32]
|
||
}
|
||
|
||
fn to_lab(p: &Profile, dev: &[f32], intent: Intent) -> [f64; 3] {
|
||
let t = Transform::new(p, lab(), intent, false).unwrap();
|
||
let mut o = [0.0f32; 3];
|
||
t.eval(dev, &mut o);
|
||
dec(&o)
|
||
}
|
||
|
||
#[test]
|
||
fn builtins_encode_and_reparse() {
|
||
for b in Builtin::ALL {
|
||
let p = b.profile();
|
||
let bytes = p.to_bytes();
|
||
let q = Profile::parse(&bytes).unwrap_or_else(|e| panic!("{b:?}: {e}"));
|
||
assert_eq!(q.description, b.description(), "{b:?}");
|
||
assert_eq!(q.color_space, p.color_space);
|
||
assert_eq!(&*q.to_bytes(), &*bytes, "{b:?} bytes are kept verbatim");
|
||
assert_eq!(Builtin::from_id(b.id()), Some(b));
|
||
// Every builtin converts to Lab and back.
|
||
Transform::new(p, lab(), Intent::RelativeColorimetric, false).unwrap();
|
||
Transform::new(lab(), p, Intent::RelativeColorimetric, false).unwrap();
|
||
}
|
||
assert_eq!(Builtin::from_id("Adobe RGB (1998)"), Some(Builtin::AdobeRgbCompat));
|
||
}
|
||
|
||
#[test]
|
||
fn srgb_known_lab_values() {
|
||
let w = to_lab(srgb(), &[1.0, 1.0, 1.0], Intent::RelativeColorimetric);
|
||
assert!((w[0] - 100.0).abs() < 0.01 && w[1].abs() < 0.01 && w[2].abs() < 0.01, "white {w:?}");
|
||
let k = to_lab(srgb(), &[0.0, 0.0, 0.0], Intent::RelativeColorimetric);
|
||
assert!(k[0].abs() < 0.01, "black {k:?}");
|
||
// ICC PCS is D50: sRGB red Bradford-adapted to D50 is L*a*b* ≈ (54.29, 80.80, 69.89).
|
||
// (53.24, 80.09, 67.20) is the same colour referenced to D65 without adaptation.
|
||
let r = to_lab(srgb(), &[1.0, 0.0, 0.0], Intent::RelativeColorimetric);
|
||
assert!(delta_e76(r, [54.29, 80.80, 69.89]) < 0.15, "red {r:?}");
|
||
let g = to_lab(srgb(), &[0.0, 1.0, 0.0], Intent::RelativeColorimetric);
|
||
assert!(delta_e76(g, [87.82, -79.29, 80.99]) < 0.3, "green {g:?}");
|
||
let b = to_lab(srgb(), &[0.0, 0.0, 1.0], Intent::RelativeColorimetric);
|
||
assert!(delta_e76(b, [29.57, 68.30, -112.03]) < 0.3, "blue {b:?}");
|
||
// Grays are neutral in every RGB builtin.
|
||
for bi in [Builtin::Srgb, Builtin::DisplayP3, Builtin::AdobeRgbCompat, Builtin::ProPhotoCompat, Builtin::LinearSrgb, Builtin::Rec2020] {
|
||
let m = to_lab(bi.profile(), &[0.5, 0.5, 0.5], Intent::RelativeColorimetric);
|
||
assert!(m[1].abs() < 0.02 && m[2].abs() < 0.02, "{bi:?} gray {m:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rgb_lab_rgb_roundtrip_8bit_and_float() {
|
||
let to = Transform::new(srgb(), lab(), Intent::RelativeColorimetric, false).unwrap();
|
||
let back = Transform::new(lab(), srgb(), Intent::RelativeColorimetric, false).unwrap();
|
||
// 8-bit RGB through a float Lab intermediate: exact to the 8-bit level.
|
||
let mut src8 = Vec::new();
|
||
for r in (0..256).step_by(5) {
|
||
for g in (0..256).step_by(7) {
|
||
for b in (0..256).step_by(9) {
|
||
src8.extend_from_slice(&[r as u8, g as u8, b as u8]);
|
||
}
|
||
}
|
||
}
|
||
let f: Vec<f32> = src8.iter().map(|v| *v as f32 / 255.0).collect();
|
||
let mut labf = vec![0.0f32; f.len()];
|
||
to.convert_f32(&f, 3, &mut labf, 3, false);
|
||
let mut rgbf = vec![0.0f32; f.len()];
|
||
back.convert_f32(&labf, 3, &mut rgbf, 3, false);
|
||
for (a, b) in src8.iter().zip(&rgbf) {
|
||
assert!((*a as f32 - b * 255.0).abs() <= 1.0, "{a} vs {}", b * 255.0);
|
||
}
|
||
// 8-bit RGB stored as 16-bit Lab then back to 8-bit through the lookup tables.
|
||
let lab16: Vec<u16> = labf.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect();
|
||
let mut rgb16 = vec![0u16; lab16.len()];
|
||
back.convert_u16(&lab16, 3, &mut rgb16, 3, false);
|
||
for (a, b) in src8.iter().zip(&rgb16) {
|
||
assert!((*a as f32 - *b as f32 / 257.0).abs() <= 1.0, "{a} vs {}", *b as f32 / 257.0);
|
||
}
|
||
// Float: 1e-4.
|
||
for (a, b) in f.iter().zip(&rgbf) {
|
||
assert!((a - b).abs() < 1e-4, "{a} vs {b}");
|
||
}
|
||
// Fast 8-bit path: RGB8 → Lab8-grid → compare with exact within 1 level of Lab encoding.
|
||
let mut lab8 = vec![0u8; src8.len()];
|
||
to.convert_u8(&src8, 3, &mut lab8, 3, false);
|
||
for (q, e) in lab8.iter().zip(&labf) {
|
||
assert!((*q as f32 - e * 255.0).abs() <= 1.0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rgb_to_rgb_matrix_shaper_paths_agree() {
|
||
// Integer lookup path vs exact pipeline for pure-gamma destinations (hard near black).
|
||
for dst in [Builtin::AdobeRgbCompat, Builtin::ProPhotoCompat, Builtin::DisplayP3, Builtin::LinearSrgb] {
|
||
let t = Transform::new(srgb(), dst.profile(), Intent::RelativeColorimetric, false).unwrap();
|
||
let src: Vec<u8> = (0..=255u8).flat_map(|v| [v, v / 2, 255 - v]).collect();
|
||
let mut out = vec![0u8; src.len()];
|
||
t.convert_u8(&src, 3, &mut out, 3, false);
|
||
for (px, o) in src.chunks(3).zip(out.chunks(3)) {
|
||
let mut e = [0.0f32; 3];
|
||
t.eval(&[px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0], &mut e);
|
||
for k in 0..3 {
|
||
assert!((o[k] as f32 - e[k].clamp(0.0, 1.0) * 255.0).abs() <= 0.6, "{dst:?} {px:?}: {o:?} vs {e:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn identity_transform_is_identity() {
|
||
let t = Transform::new(srgb(), srgb(), Intent::RelativeColorimetric, true).unwrap();
|
||
let src: Vec<u8> = (0..=255u8).flat_map(|v| [v, 255 - v, v / 3, 77]).collect();
|
||
let mut out = vec![0u8; src.len()];
|
||
t.convert_u8(&src, 4, &mut out, 4, true);
|
||
assert_eq!(src, out);
|
||
}
|
||
|
||
#[test]
|
||
fn cmyk_profile_is_monotonic_and_in_range() {
|
||
let p = cmyk();
|
||
assert_eq!(p.color_space, ColorSpace::Cmyk);
|
||
// CMYK → Lab: L* decreases with every ink.
|
||
for ink in 0..4 {
|
||
let mut prev = f64::INFINITY;
|
||
for i in 0..=32 {
|
||
let mut v = [0.1f32, 0.1, 0.1, 0.1];
|
||
v[ink] = i as f32 / 32.0;
|
||
let l = to_lab(p, &v, Intent::RelativeColorimetric)[0];
|
||
assert!(l <= prev + 0.05, "ink {ink} at {i}: {l} > {prev}");
|
||
prev = l;
|
||
}
|
||
}
|
||
// Paper is L* 100 (relative).
|
||
let w = to_lab(p, &[0.0; 4], Intent::RelativeColorimetric);
|
||
assert!(delta_e76(w, [100.0, 0.0, 0.0]) < 0.5, "{w:?}");
|
||
// sRGB gray ramp → CMYK: in range, K never decreases, total ink within the limit.
|
||
for intent in [Intent::Perceptual, Intent::RelativeColorimetric] {
|
||
let t = Transform::new(srgb(), p, intent, true).unwrap();
|
||
let mut prev_k = -1.0f32;
|
||
let mut prev_l = f64::INFINITY;
|
||
let back = Transform::new(p, lab(), Intent::RelativeColorimetric, false).unwrap();
|
||
for v in (0..=255).rev().step_by(5) {
|
||
let g = v as f32 / 255.0;
|
||
let mut o = [0.0f32; 4];
|
||
t.eval(&[g, g, g], &mut o);
|
||
assert!(o.iter().all(|c| (-1e-6..=1.0 + 1e-6).contains(c)), "{o:?}");
|
||
assert!(o.iter().sum::<f32>() <= 3.0 + 0.02, "TAC {o:?}");
|
||
assert!(o[3] >= prev_k - 0.01, "{intent:?} K not monotonic at {v}: {o:?}");
|
||
prev_k = o[3];
|
||
let mut l = [0.0f32; 3];
|
||
back.eval(&o, &mut l);
|
||
let lab = dec(&l);
|
||
assert!(lab[0] <= prev_l + 0.3, "{intent:?} L* not monotonic at {v}: {lab:?}");
|
||
assert!(lab[1].abs() < 3.0 && lab[2].abs() < 3.0, "{intent:?} gray {v} not neutral: {lab:?}");
|
||
prev_l = lab[0];
|
||
}
|
||
}
|
||
// Arbitrary colours: CMYK outputs in range for u8 and float paths.
|
||
let t = Transform::new(srgb(), p, Intent::RelativeColorimetric, true).unwrap();
|
||
let src: Vec<u8> = (0..4096u32).flat_map(|i| [(i * 37 % 256) as u8, (i * 91 % 256) as u8, (i * 13 % 256) as u8]).collect();
|
||
let mut out = vec![0u8; 4096 * 4];
|
||
t.convert_u8(&src, 3, &mut out, 4, false);
|
||
for px in out.chunks(4) {
|
||
assert!(px.iter().map(|v| *v as u32).sum::<u32>() <= 3 * 255 + 8, "{px:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn in_gamut_colours_survive_cmyk_roundtrip() {
|
||
let to = Transform::new(lab(), cmyk(), Intent::RelativeColorimetric, false).unwrap();
|
||
let back = Transform::new(cmyk(), lab(), Intent::RelativeColorimetric, false).unwrap();
|
||
for target in [[50.0, 0.0, 0.0], [70.0, 10.0, 10.0], [60.0, -20.0, -20.0], [80.0, 0.0, 40.0], [40.0, 30.0, -10.0]] {
|
||
let mut c = [0.0f32; 4];
|
||
to.eval(&enc(target), &mut c);
|
||
let mut l = [0.0f32; 3];
|
||
back.eval(&c, &mut l);
|
||
assert!(delta_e76(dec(&l), target) < 2.0, "{target:?} -> {c:?} -> {:?}", dec(&l));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn intents_differ_as_expected() {
|
||
let per = Transform::new(srgb(), cmyk(), Intent::Perceptual, false).unwrap();
|
||
let rel = Transform::new(srgb(), cmyk(), Intent::RelativeColorimetric, false).unwrap();
|
||
let sat = Transform::new(srgb(), cmyk(), Intent::Saturation, false).unwrap();
|
||
let (mut a, mut b, mut s) = ([0.0f32; 4], [0.0f32; 4], [0.0f32; 4]);
|
||
// Out-of-gamut sRGB blue: perceptual compresses, relative clips: different separations.
|
||
per.eval(&[0.0, 0.2, 1.0], &mut a);
|
||
rel.eval(&[0.0, 0.2, 1.0], &mut b);
|
||
sat.eval(&[0.0, 0.2, 1.0], &mut s);
|
||
let d: f32 = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).sum();
|
||
assert!(d > 0.03, "perceptual {a:?} vs relative {b:?}");
|
||
assert_eq!(a, s, "saturation shares the perceptual table");
|
||
// Relative vs absolute on CMYK paper white shown in sRGB: relative is white, absolute shows
|
||
// the paper tint (darker, slightly blue: paper Lab 95/0/−2).
|
||
let rel = Transform::new(cmyk(), srgb(), Intent::RelativeColorimetric, false).unwrap();
|
||
let abs = Transform::new(cmyk(), srgb(), Intent::AbsoluteColorimetric, false).unwrap();
|
||
let (mut w1, mut w2) = ([0.0f32; 3], [0.0f32; 3]);
|
||
rel.eval(&[0.0; 4], &mut w1);
|
||
abs.eval(&[0.0; 4], &mut w2);
|
||
assert!(w1.iter().all(|v| *v > 0.995), "{w1:?}");
|
||
assert!(w2[0] < 0.97 && w2[2] > w2[0], "{w2:?}");
|
||
let l = to_lab(cmyk(), &[0.0; 4], Intent::AbsoluteColorimetric);
|
||
assert!(delta_e76(l, [95.0, 0.0, -2.0]) < 0.6, "absolute paper {l:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn black_point_compensation() {
|
||
let bp = photocraft_cms::black_point(cmyk(), Intent::RelativeColorimetric, false).unwrap();
|
||
let bl = math::xyz_to_lab([bp * 0.9642, bp, bp * 0.8249], math::D50)[0];
|
||
assert!(bl > 4.0 && bl < 20.0, "CMYK black L* {bl}");
|
||
assert_eq!(photocraft_cms::black_point(srgb(), Intent::RelativeColorimetric, true).unwrap(), 0.0);
|
||
// sRGB → CMYK: without BPC, deep shadows below the paper's black clip together; with BPC
|
||
// they stay distinct.
|
||
let with = Transform::new(srgb(), cmyk(), Intent::RelativeColorimetric, true).unwrap();
|
||
let without = Transform::new(srgb(), cmyk(), Intent::RelativeColorimetric, false).unwrap();
|
||
let back = Transform::new(cmyk(), lab(), Intent::RelativeColorimetric, false).unwrap();
|
||
let l_of = |t: &Transform, v: f32| {
|
||
let mut c = [0.0f32; 4];
|
||
t.eval(&[v, v, v], &mut c);
|
||
let mut l = [0.0f32; 3];
|
||
back.eval(&c, &mut l);
|
||
dec(&l)[0]
|
||
};
|
||
let (w0, w1) = (l_of(&with, 0.0), l_of(&with, 0.08));
|
||
let (n0, n1) = (l_of(&without, 0.0), l_of(&without, 0.08));
|
||
assert!(w1 - w0 > 1.5, "BPC keeps shadow detail: {w0} {w1}");
|
||
assert!((n1 - n0).abs() < w1 - w0, "no BPC compresses shadows: {n0} {n1}");
|
||
// CMYK → sRGB: with BPC the darkest CMYK maps to (near) sRGB black.
|
||
let dark = {
|
||
let mut c = [0.0f32; 4];
|
||
with.eval(&[0.0, 0.0, 0.0], &mut c);
|
||
c
|
||
};
|
||
let rgb_with = Transform::new(cmyk(), srgb(), Intent::RelativeColorimetric, true).unwrap();
|
||
let rgb_without = Transform::new(cmyk(), srgb(), Intent::RelativeColorimetric, false).unwrap();
|
||
let (mut a, mut b) = ([0.0f32; 3], [0.0f32; 3]);
|
||
rgb_with.eval(&dark, &mut a);
|
||
rgb_without.eval(&dark, &mut b);
|
||
assert!(a[1] < 0.03 && b[1] > a[1] + 0.03, "with {a:?} without {b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn tetrahedral_beats_trilinear() {
|
||
// Sample sRGB → Lab on a coarse grid and compare interpolation against the exact pipeline.
|
||
let exact = Transform::new(srgb(), lab(), Intent::RelativeColorimetric, false).unwrap();
|
||
let clut = Clut::sample(vec![9, 9, 9], 3, |i, o| exact.eval(i, o));
|
||
let (mut tet_max, mut tri_max, mut tet_sum, mut tri_sum, mut tet_neutral, mut tri_neutral) = (0.0f64, 0.0f64, 0.0, 0.0, 0.0f64, 0.0f64);
|
||
let mut n = 0.0;
|
||
let mut seed = 12345u32;
|
||
let mut rnd = || {
|
||
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
||
(seed >> 8) as f32 / (1 << 24) as f32
|
||
};
|
||
for i in 0..4000 {
|
||
let p = if i < 200 {
|
||
let v = i as f32 / 199.0;
|
||
[v, v, v]
|
||
} else {
|
||
[rnd(), rnd(), rnd()]
|
||
};
|
||
let (mut e, mut a, mut b) = ([0.0f32; 3], [0.0f32; 3], [0.0f32; 3]);
|
||
exact.eval(&p, &mut e);
|
||
clut.eval(&p, &mut a);
|
||
clut.eval_trilinear(&p, &mut b);
|
||
let (da, db) = (delta_e76(dec(&a), dec(&e)), delta_e76(dec(&b), dec(&e)));
|
||
if i < 200 {
|
||
tet_neutral = tet_neutral.max(dec(&a)[1].abs().max(dec(&a)[2].abs()));
|
||
tri_neutral = tri_neutral.max(dec(&b)[1].abs().max(dec(&b)[2].abs()));
|
||
}
|
||
tet_max = tet_max.max(da);
|
||
tri_max = tri_max.max(db);
|
||
tet_sum += da;
|
||
tri_sum += db;
|
||
n += 1.0;
|
||
}
|
||
eprintln!("tetra mean {:.4} max {:.4} | trilinear mean {:.4} max {:.4}", tet_sum / n, tet_max, tri_sum / n, tri_max);
|
||
assert!(tet_neutral < 0.01, "tetrahedral keeps neutrals neutral: {tet_neutral}");
|
||
assert!(tet_sum <= tri_sum * 1.05, "tetrahedral mean error {} vs trilinear {}", tet_sum / n, tri_sum / n);
|
||
assert!(tet_max < 4.0);
|
||
let _ = tri_neutral;
|
||
}
|
||
|
||
#[test]
|
||
fn proof_and_display_lut() {
|
||
let p = Transform::proof(srgb(), cmyk(), srgb(), Intent::RelativeColorimetric, true, false).unwrap();
|
||
let mut o = [0.0f32; 3];
|
||
// Neutral gray stays (nearly) neutral through the proof.
|
||
p.eval(&[0.5, 0.5, 0.5], &mut o);
|
||
assert!((o[0] - o[1]).abs() < 0.02 && (o[1] - o[2]).abs() < 0.03, "{o:?}");
|
||
// Saturated blue is desaturated by the CMYK gamut.
|
||
p.eval(&[0.0, 0.0, 1.0], &mut o);
|
||
assert!(o[0] > 0.1 || o[1] > 0.1, "{o:?}");
|
||
// Paper simulation darkens white.
|
||
let sim = Transform::proof(srgb(), cmyk(), srgb(), Intent::RelativeColorimetric, true, true).unwrap();
|
||
sim.eval(&[1.0, 1.0, 1.0], &mut o);
|
||
assert!(o[0] < 0.97, "{o:?}");
|
||
|
||
let t = Transform::new(srgb(), Builtin::DisplayP3.profile(), Intent::RelativeColorimetric, false).unwrap();
|
||
let lut = Lut3d::from_transform(&t, 33);
|
||
assert_eq!(lut.to_rgba16f_bytes().len(), 33 * 33 * 33 * 8);
|
||
let mut worst = 0.0f32;
|
||
for i in 0..500u32 {
|
||
let c = [(i * 7 % 101) as f32 / 100.0, (i * 13 % 101) as f32 / 100.0, (i * 29 % 101) as f32 / 100.0];
|
||
let mut e = [0.0f32; 3];
|
||
t.eval(&c, &mut e);
|
||
let s = lut.sample(c);
|
||
for k in 0..3 {
|
||
worst = worst.max((s[k] - e[k]).abs());
|
||
}
|
||
}
|
||
assert!(worst < 2.0 / 255.0, "display LUT error {worst}");
|
||
}
|
||
|
||
#[test]
|
||
fn gray_profiles() {
|
||
let t = Transform::new(Builtin::SGray.profile(), srgb(), Intent::RelativeColorimetric, false).unwrap();
|
||
for v in [0.0f32, 0.2, 0.5, 0.9, 1.0] {
|
||
let mut o = [0.0f32; 3];
|
||
t.eval(&[v], &mut o);
|
||
for c in o {
|
||
assert!((c - v).abs() < 1e-3, "sGray {v} -> {o:?}");
|
||
}
|
||
}
|
||
let t = Transform::new(srgb(), Builtin::GrayGamma22.profile(), Intent::RelativeColorimetric, false).unwrap();
|
||
let mut o = [0.0f32; 1];
|
||
t.eval(&[1.0, 1.0, 1.0], &mut o);
|
||
assert!((o[0] - 1.0).abs() < 1e-3);
|
||
t.eval(&[0.0, 0.0, 0.0], &mut o);
|
||
assert!(o[0].abs() < 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn hdr_floats_survive_matrix_transforms() {
|
||
let lin = Builtin::LinearSrgb.profile();
|
||
let t =
|
||
Transform::with_options(lin, Builtin::Rec2020.profile(), TransformOptions { intent: Intent::RelativeColorimetric, bpc: false, precise_float: true })
|
||
.unwrap();
|
||
let back = Transform::new(Builtin::Rec2020.profile(), lin, Intent::RelativeColorimetric, false).unwrap();
|
||
let src = [4.0f32, 2.0, 0.5];
|
||
let mut mid = [0.0f32; 3];
|
||
let mut out = [0.0f32; 3];
|
||
t.eval(&src, &mut mid);
|
||
back.eval(&mid, &mut out);
|
||
for k in 0..3 {
|
||
assert!((out[k] - src[k]).abs() < 1e-3 * src[k].max(1.0), "{src:?} -> {mid:?} -> {out:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn malformed_profiles_are_rejected_without_panicking() {
|
||
assert!(Profile::parse(&[]).is_err());
|
||
assert!(Profile::parse(&[0u8; 200]).is_err());
|
||
let good = srgb().to_bytes();
|
||
for cut in [100, 131, 140, 200, good.len() - 20] {
|
||
let _ = Profile::parse(&good[..cut]);
|
||
}
|
||
let mut bad = good.to_vec();
|
||
for i in (128..bad.len()).step_by(7) {
|
||
bad[i] ^= 0x5A;
|
||
let _ = Profile::parse(&bad);
|
||
}
|
||
let c = cmyk().to_bytes();
|
||
let mut bad = c.to_vec();
|
||
for i in (128..400).step_by(3) {
|
||
bad[i] = 0xFF;
|
||
if let Ok(p) = Profile::parse(&bad) {
|
||
let _ = Transform::new(&p, srgb(), Intent::Perceptual, true);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Parses every profile the OS ships (macOS ColorSync; read-only, nothing is copied) and
|
||
/// converts through each.
|
||
#[test]
|
||
fn system_profiles() {
|
||
let dirs = ["/System/Library/ColorSync/Profiles", "/Library/ColorSync/Profiles", "/usr/share/color/icc", "/usr/share/color/icc/colord"];
|
||
let mut seen = 0;
|
||
for d in dirs {
|
||
let Ok(rd) = std::fs::read_dir(d) else { continue };
|
||
for e in rd.flatten() {
|
||
let path = e.path();
|
||
if !matches!(path.extension().and_then(|x| x.to_str()), Some("icc" | "icm" | "ICC" | "ICM")) {
|
||
continue;
|
||
}
|
||
let Ok(bytes) = std::fs::read(&path) else { continue };
|
||
let p = match Profile::parse(&bytes) {
|
||
Ok(p) => p,
|
||
// Named-colour profiles are never used for conversions; colord's
|
||
// x11-colors.icc carries a stub B2A0 with overlapping elements.
|
||
Err(_) if bytes.get(12..16) == Some(b"nmcl") => continue,
|
||
Err(e) => panic!("{}: {e}", path.display()),
|
||
};
|
||
seen += 1;
|
||
if !matches!(
|
||
p.class,
|
||
photocraft_cms::ProfileClass::Input
|
||
| photocraft_cms::ProfileClass::Display
|
||
| photocraft_cms::ProfileClass::Output
|
||
| photocraft_cms::ProfileClass::ColorSpace
|
||
) {
|
||
continue;
|
||
}
|
||
assert!(!p.description.is_empty(), "{}", path.display());
|
||
if matches!(p.color_space, ColorSpace::Rgb | ColorSpace::Gray | ColorSpace::Cmyk | ColorSpace::Lab) {
|
||
for intent in Intent::ALL {
|
||
let t = Transform::new(&p, lab(), intent, true).unwrap_or_else(|e| panic!("{} {intent:?}: {e}", path.display()));
|
||
let n = p.channels();
|
||
let mut o = [0.0f32; 3];
|
||
t.eval(&vec![0.5; n], &mut o);
|
||
assert!(o.iter().all(|v| v.is_finite()), "{}", path.display());
|
||
}
|
||
// Additive white is (near) L* 100 for display profiles in relative colorimetric.
|
||
if p.color_space == ColorSpace::Rgb {
|
||
let w = to_lab(&p, &[1.0, 1.0, 1.0], Intent::RelativeColorimetric);
|
||
assert!((w[0] - 100.0).abs() < 1.5, "{} white {w:?}", path.display());
|
||
}
|
||
}
|
||
if p.color_space == ColorSpace::Cmyk {
|
||
// Real CMYK profile: paper lighter than 100 % K.
|
||
let w = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
|
||
let k = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
|
||
assert!(w[0] > 90.0 && k[0] < 40.0, "{}: {w:?} {k:?}", path.display());
|
||
// And it round-trips through its own BToA.
|
||
if p.b2a[1].is_some() {
|
||
let to = Transform::new(lab(), &p, Intent::RelativeColorimetric, false).unwrap();
|
||
let back = Transform::new(&p, lab(), Intent::RelativeColorimetric, false).unwrap();
|
||
let mut c = [0.0f32; 4];
|
||
to.eval(&enc([60.0, 10.0, -10.0]), &mut c);
|
||
let mut l = [0.0f32; 3];
|
||
back.eval(&c, &mut l);
|
||
assert!(delta_e76(dec(&l), [60.0, 10.0, -10.0]) < 3.0, "{}: {:?}", path.display(), dec(&l));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
eprintln!("parsed {seen} system profiles");
|
||
}
|
||
|
||
/// Independent oracle: moxcms (BSD-3/Apache-2.0, dev-dependency only) converting with the same
|
||
/// profile bytes.
|
||
#[test]
|
||
fn oracle_moxcms_rgb() {
|
||
use moxcms::{ColorProfile, Layout, TransformOptions as MoxOpts};
|
||
for dst in [Builtin::DisplayP3, Builtin::AdobeRgbCompat, Builtin::ProPhotoCompat] {
|
||
let s = ColorProfile::new_from_slice(&srgb().to_bytes()).unwrap();
|
||
let d = ColorProfile::new_from_slice(&dst.profile().to_bytes()).unwrap();
|
||
let mt = s.create_transform_8bit(Layout::Rgb, &d, Layout::Rgb, MoxOpts::default()).unwrap();
|
||
let src: Vec<u8> = (0..4096u32).flat_map(|i| [(i * 37 % 256) as u8, (i * 91 % 256) as u8, (i * 13 % 256) as u8]).collect();
|
||
let mut theirs = vec![0u8; src.len()];
|
||
mt.transform(&src, &mut theirs).unwrap();
|
||
let ours_t = Transform::new(srgb(), dst.profile(), Intent::RelativeColorimetric, false).unwrap();
|
||
let mut ours = vec![0u8; src.len()];
|
||
ours_t.convert_u8(&src, 3, &mut ours, 3, false);
|
||
let worst = ours.iter().zip(&theirs).map(|(a, b)| (*a as i32 - *b as i32).abs()).max().unwrap();
|
||
assert!(worst <= 2, "{dst:?}: max diff {worst}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn oracle_moxcms_system_cmyk() {
|
||
use moxcms::{ColorProfile, Layout, RenderingIntent, TransformOptions as MoxOpts};
|
||
let path = "/System/Library/ColorSync/Profiles/Generic CMYK Profile.icc";
|
||
let Ok(bytes) = std::fs::read(path) else { return };
|
||
let ours_p = Profile::parse(&bytes).unwrap();
|
||
let theirs_p = ColorProfile::new_from_slice(&bytes).unwrap();
|
||
let s = ColorProfile::new_from_slice(&srgb().to_bytes()).unwrap();
|
||
let opts = MoxOpts { rendering_intent: RenderingIntent::RelativeColorimetric, ..Default::default() };
|
||
let Ok(mt) = theirs_p.create_transform_8bit(Layout::Rgba, &s, Layout::Rgb, opts) else { return };
|
||
let src: Vec<u8> = (0..2048u32).flat_map(|i| [(i * 37 % 256) as u8, (i * 91 % 256) as u8, (i * 13 % 256) as u8, (i * 57 % 256) as u8]).collect();
|
||
let mut theirs = vec![0u8; 2048 * 3];
|
||
mt.transform(&src, &mut theirs).unwrap();
|
||
let t = Transform::new(&ours_p, srgb(), Intent::RelativeColorimetric, false).unwrap();
|
||
let mut ours = vec![0u8; 2048 * 3];
|
||
t.convert_u8(&src, 4, &mut ours, 3, false);
|
||
let diffs: Vec<i32> = ours.iter().zip(&theirs).map(|(a, b)| (*a as i32 - *b as i32).abs()).collect();
|
||
let worst = *diffs.iter().max().unwrap();
|
||
let mean = diffs.iter().sum::<i32>() as f64 / diffs.len() as f64;
|
||
eprintln!("Generic CMYK → sRGB vs moxcms: max {worst}, mean {mean:.3}");
|
||
let wi = diffs.iter().position(|d| *d == worst).unwrap() / 3;
|
||
eprintln!("worst px cmyk {:?}: ours {:?} theirs {:?}", &src[wi * 4..wi * 4 + 4], &ours[wi * 3..wi * 3 + 3], &theirs[wi * 3..wi * 3 + 3]);
|
||
// Outliers sit at the sRGB toe (slope 12.92) on near-gamut-edge colours, where moxcms's
|
||
// own output interpolation differs; an independent float computation of one such pixel
|
||
// (Lab 48.45/−32.55/−4.90 → sRGB red 0.86) matches ours (0.73), not moxcms (19).
|
||
let mut sorted = diffs.clone();
|
||
sorted.sort_unstable();
|
||
let p99 = sorted[sorted.len() * 99 / 100];
|
||
assert!(mean < 1.5 && p99 <= 6, "max {worst} p99 {p99} mean {mean}");
|
||
}
|