craft-cms: built-in Coated FOGRA39 profile fitted to Fogra's data

Add Builtin::CoatedFogra39 ("Coated FOGRA39 (fitted, craft-cms)",
profiles/craft-coated-fogra39.icc: AToB 11^4, BToA 33^3, 330% TAC,
medium GCR). It is fitted to Fogra's FOGRA39 characterisation data
(tests/data/FOGRA39.txt, unmodified, 1617 patches): a Yule-Nielsen
Neugebauer model with free overprints, per-ink tone curves and n, plus
a Gaussian-kernel ridge correction of the residuals. Against the
patches, relative colorimetric through the AToB: mean dE00 0.07,
p95 0.18, max 0.53. The BToA tables invert the AToB table
(synth::cmyk_profile_from_model); the Photocraft coated profile and its
synth defaults are unchanged.

Builtin::from_profile_name maps the names documents carry for
FOGRA39-based profiles (Coated FOGRA39, ISO Coated v2 (ECI)) to the new
profile and every built-in by its description. Adds math::delta_e2000.

Refs #36
This commit is contained in:
aiongg committed 2026-10-13 01:23:45 +00:00
1 parent dd9e3f1bc3
commit f97ed953f4
12 files changed
+2424 -27

No files matched your search

+2
View File
@@ -7,3 +7,5 @@ author, source and license. Library code and original assets are under the repos
| File | Author | Source | License |
|---|---|---|---|
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft contributors; generated by `craft_cms::synth::coated_cmyk` | [PhotoCraft `crates/cms`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms) | CC0-1.0 |
| `crates/cms/profiles/craft-coated-fogra39.icc` | craft-libs contributors; generated by `crates/cms/tests/fogra39.rs` from the FOGRA39 data below | this repository | CC0-1.0 |
| `crates/cms/tests/data/FOGRA39.txt` (unmodified) | Fogra | [Fogra characterisation data](https://fogra.org/en/downloads/work-tools/characterisation-data), `FOGRA39_to_FOGRA60_v2.zip` | Fogra terms: free use and redistribution, unmodified, with Fogra identified as the source; "FOGRA39" identifies the reference data only and implies no certification, approval or endorsement by Fogra (full text in [NOTICE](NOTICE)) |
+10
View File
@@ -9,6 +9,16 @@ This product bundles third-party material under its own licenses, listed below.
list (author, source, license) is in ATTRIBUTION.md.
crates/cms/profiles/photocraft-coated-cmyk.icc: CC0-1.0, generated by crates/cms (see below).
crates/cms/profiles/craft-coated-fogra39.icc: CC0-1.0, generated by crates/cms from the FOGRA39
characterisation data below.
crates/cms/tests/data/FOGRA39.txt: FOGRA39 characterisation data, distributed unmodified.
Source: Fogra (Fogra Forschungsinstitut für Medientechnologien e.V.), www.fogra.org, from
https://fogra.org/fileadmin/files/7_downloads/Charakterisierungsdaten/FOGRA39_to_FOGRA60_v2.zip
Fogra's terms: "Free use and redistribution of the Fogra characterisation data is permitted,
including redistribution as part of commercial and non-commercial software, provided that
the data are distributed unmodified and Fogra is identified as the source. The designation
FOGRAxx may be used solely to identify the respective reference data. Such use does not
imply certification, approval or endorsement by Fogra."
Source provenance
+2
View File
@@ -6,6 +6,8 @@ license.workspace = true
rust-version.workspace = true
repository.workspace = true
description = "Pure-Rust ICC v2/v4 colour management: profile parsing and writing, transforms, rendering intents, black point compensation"
# The FOGRA39 measurement data and the tests that read it stay in the repository.
exclude = ["tests/data", "tests/fogra39.rs"]
[lints]
workspace = true
+70 -10
View File
@@ -30,6 +30,8 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
* `lutfile::parse(name, bytes)` / `lutfile::write_cube(&lut)`: `.cube`, `.3dl` and `.look` LUT
files; `lutfile::builtin(id)` returns generated looks (`lutfile::BUILTIN`).
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
* `Builtin::from_profile_name(desc)`: the built-in for a profile a document names (see
[Selecting by profile name](#selecting-by-profile-name)).
* `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.
@@ -65,25 +67,83 @@ published with, so profiles embedded in existing files still match byte for byte
| `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve (default gray working space) |
| `lab-d50` | Lab identity (v4 encoding) |
| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)**: default CMYK |
| `coated-fogra39` | Coated FOGRA39 (fitted, craft-cms): fitted to Fogra's FOGRA39 data, 330% TAC, medium GCR |
### The CMYK profile
### The CMYK profiles
Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39) are proprietary, and the freely
downloadable characterisation-based profiles (ECI/FOGRA, colord, Ghostscript) carry
redistribution conditions or unclear licences, so this crate generates its own
(`src/synth.rs`): a Yule–Nielsen modified Neugebauer model (n = 2) over the 16 overprints with
redistribution conditions or unclear licences, so this crate generates its own. Both share the
separation code in `src/synth.rs`: GCR black generation (starts at 20 % gray component, K max
95 %), a bounded least-squares CMY solve under the total ink limit, and perceptual tables with
L* black-point scaling and soft chroma compression against a sampled gamut boundary.
**`coated-cmyk`** (`profiles/photocraft-coated-cmyk.icc`, 237 KB, AToB 11⁴ lut16, BToA 21³
lut16) comes from a Yule–Nielsen modified Neugebauer model (n = 2) over the 16 overprints with
ISO 12647-2 coated-paper Lab aims for paper/C/M/Y/RGB overprints, parabolic dot gain (14 % CMY,
17 % K at 50 %), GCR black generation (starts at 20 % gray component, K max 95 %), total ink
limit 300 %, perceptual tables with L* black-point scaling and soft chroma compression against a
sampled gamut boundary. `profiles/photocraft-coated-cmyk.icc` (237 KB, AToB 11⁴ lut16, BToA
21³ lut16) is that output, dedicated to the public domain (CC0-1.0). Regenerate with
17 % K at 50 %) and a 300 % ink limit. It is a plausible coated-offset profile, not a measured
characterisation: its midtones are darker than FOGRA39 (mean ΔE00 6.7 against the FOGRA39
patches, oranges such as 0/50/100/0 about 5 ΔE00 off).
**`coated-fogra39`** (`profiles/craft-coated-fogra39.icc`, 664 KB, AToB 11⁴ lut16, BToA 33³
lut16, 330 % ink limit) reproduces the colours of documents set up for Coated FOGRA39 or ISO
Coated v2. It is fitted to Fogra's FOGRA39 characterisation data (`tests/data/FOGRA39.txt`,
1617 IT8.7/4 patches, CMYK with D50 XYZ and Lab; ISO 12647-2:2004 Amd 1 offset on paper type
1/2): a Yule–Nielsen modified Neugebauer model with free overprints, per-ink tone curves and a
fitted n (1.45) is solved by least squares against all patches (mean ΔE00 1.1 on its own), then
a Gaussian-kernel ridge regression of the remaining Lab errors over CMYK (σ = 0.2, λ = 0.01)
corrects it before the AToB table is sampled. The BToA tables invert that AToB table.
| Through the AToB, relative colorimetric, against the 1617 FOGRA39 patches | mean ΔE00 | p95 | max |
|---|---|---|---|
| `coated-fogra39` | 0.07 | 0.18 | 0.53 |
| `coated-fogra39` fit, against the 10 % of patches held out of it | 0.11 | 0.32 | 0.79 |
| `coated-cmyk` | 6.67 | 13.4 | 16.1 |
Lab → CMYK → Lab through the profile (relative colorimetric) returns colours inside the gamut
within mean ΔE00 0.24 (p95 1.0, max 2.5). Converted to sRGB (relative colorimetric with black
point compensation), process solids, overprints and oranges land within 0.8 ΔE00 of reference
values for Coated FOGRA39 (`tests/fogra39.rs`).
"FOGRA39" identifies the reference data the profile is fitted to; it does not imply
certification, approval or endorsement by Fogra. The data file is Fogra's, distributed
unmodified with Fogra identified as the source as Fogra's terms require (see `NOTICE`); it is
left out of the published crate, as only the tests read it.
Regenerate the profiles with
```sh
CRAFT_CMS_REGEN_PROFILES=1 cargo test -p craft-cms --release --test regen
CRAFT_CMS_REGEN_PROFILES=1 cargo test -p craft-cms --release --test regen --test fogra39
```
It is a plausible coated-offset profile, not a measured characterisation: use your printer's
profile for production print.
Use your printer's profile for production print.
### Selecting by profile name
`Builtin::from_profile_name` maps the profile description a document carries to a built-in,
ignoring case and whitespace:
| Name in the document | Built-in |
|---|---|
| Coated FOGRA39 (ISO 12647-2:2004), Coated FOGRA39 | `coated-fogra39` |
| ISO Coated v2 (ECI), ISO Coated v2 300% (ECI) | `coated-fogra39` |
| each built-in's own description (e.g. sRGB IEC61966-2.1) | that built-in |
| anything else | `None` |
ECI's ISO Coated v2 profiles are made from FOGRA39L, the same characterisation data as Adobe's
Coated FOGRA39, so they describe the same printed colours; only their separations (ink limit,
black generation) differ.
### Display transforms
Page-layout and image editors usually convert CMYK for display with relative colorimetric and
black point compensation. To match them, keep BPC on (the `TransformOptions` default):
```rust
let cmyk = Builtin::from_profile_name(&doc_profile_name).unwrap_or(Builtin::CoatedFogra39);
let t = Transform::new(cmyk.profile(), Builtin::Srgb.profile(), Intent::RelativeColorimetric, /*bpc*/ true)?;
// or: transform::cached(src, dst, TransformOptions::default())
```
## Not implemented / approximate
Binary file not shown.
+46 -2
View File
@@ -14,6 +14,13 @@ use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
/// the name of the crate that first generated them.
pub const COPYRIGHT: &str = "No copyright: CC0-1.0 public domain dedication. Generated by photocraft-cms.";
/// Description of [`Builtin::CoatedFogra39`]. "FOGRA39" identifies the reference data the
/// profile is fitted to; it implies no certification, approval or endorsement by Fogra.
pub const COATED_FOGRA39_DESCRIPTION: &str = "Coated FOGRA39 (fitted, craft-cms)";
/// Copyright tag text of [`Builtin::CoatedFogra39`].
pub const COATED_FOGRA39_COPYRIGHT: &str = "No copyright: CC0-1.0 public domain dedication. Generated by craft-cms, fitted to the FOGRA39 characterisation data (source: Fogra, www.fogra.org).";
/// Identifiers of the built-in profiles.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Builtin {
@@ -37,10 +44,14 @@ pub enum Builtin {
LabD50,
/// Synthetic coated-offset CMYK (300 % total ink, medium GCR); see [`crate::synth`].
CoatedCmyk,
/// Coated offset CMYK fitted to Fogra's FOGRA39 characterisation data (ISO 12647-2:2004
/// Amd 1, paper type 1/2): 330 % total ink, medium GCR. The colours of documents whose
/// CMYK profile is Coated FOGRA39 or ISO Coated v2 (see [`Builtin::from_profile_name`]).
CoatedFogra39,
}
impl Builtin {
pub const ALL: [Builtin; 10] = [
pub const ALL: [Builtin; 11] = [
Builtin::Srgb,
Builtin::DisplayP3,
Builtin::AdobeRgbCompat,
@@ -51,6 +62,7 @@ impl Builtin {
Builtin::SGray,
Builtin::LabD50,
Builtin::CoatedCmyk,
Builtin::CoatedFogra39,
];
/// Stable identifier used by commands and settings.
@@ -66,6 +78,7 @@ impl Builtin {
Builtin::SGray => "sgray",
Builtin::LabD50 => "lab-d50",
Builtin::CoatedCmyk => "coated-cmyk",
Builtin::CoatedFogra39 => "coated-fogra39",
}
}
@@ -82,6 +95,7 @@ impl Builtin {
Builtin::SGray => "sGray (sRGB tone curve, Photocraft)",
Builtin::LabD50 => "Lab D50 identity (Photocraft)",
Builtin::CoatedCmyk => crate::synth::DESCRIPTION,
Builtin::CoatedFogra39 => COATED_FOGRA39_DESCRIPTION,
}
}
@@ -99,14 +113,36 @@ impl Builtin {
"sgray" | "graysrgb" => Some(Builtin::SGray),
"lab" | "labd50" | "cielab" => Some(Builtin::LabD50),
"cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk),
"fogra39" | "coatedfogra39" => Some(Builtin::CoatedFogra39),
_ => None,
};
alias.or_else(|| Builtin::ALL.into_iter().find(|b| b.description().eq_ignore_ascii_case(s)))
}
/// The built-in that stands in for a profile a document names (its `desc` tag), compared
/// ignoring case and whitespace.
///
/// * Profiles built on the FOGRA39 characterisation data map to
/// [`Builtin::CoatedFogra39`]: Adobe's "Coated FOGRA39 (ISO 12647-2:2004)" (also seen as
/// "Coated FOGRA39"), and ECI's "ISO Coated v2 (ECI)" and "ISO Coated v2 300% (ECI)",
/// which are made from FOGRA39L, the same data. They print the same colours; only
/// their separations (total ink limit, black generation) differ.
/// * Every built-in by its own description, e.g. "sRGB IEC61966-2.1".
///
/// Unknown names give `None`: keep the document's embedded profile, or ask.
pub fn from_profile_name(name: &str) -> Option<Builtin> {
let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::<String>();
let n = key(name);
const FOGRA39: [&str; 4] = ["Coated FOGRA39 (ISO 12647-2:2004)", "Coated FOGRA39", "ISO Coated v2 (ECI)", "ISO Coated v2 300% (ECI)"];
if FOGRA39.iter().any(|f| key(f) == n) {
return Some(Builtin::CoatedFogra39);
}
Builtin::ALL.into_iter().find(|b| key(b.description()) == n)
}
/// The profile (built once per process).
pub fn profile(self) -> &'static Profile {
static CELLS: [OnceLock<Profile>; 10] = [const { OnceLock::new() }; 10];
static CELLS: [OnceLock<Profile>; 11] = [const { OnceLock::new() }; 11];
// `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`),
// so the discriminant is the cell index.
CELLS[self as usize].get_or_init(|| build(self))
@@ -263,6 +299,10 @@ fn lab() -> Profile {
.with_encoded_bytes()
}
/// The shipped FOGRA39 profile (generated by `tests/fogra39.rs`; regenerate with
/// `CRAFT_CMS_REGEN_PROFILES=1 cargo test -p craft-cms --release --test fogra39`).
pub const COATED_FOGRA39_ICC: &[u8] = include_bytes!("../profiles/craft-coated-fogra39.icc");
/// The shipped synthetic CMYK profile (generated by [`crate::synth::coated_cmyk`]; regenerate
/// with `CRAFT_CMS_REGEN_PROFILES=1 cargo test -p craft-cms --release regen`).
pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc");
@@ -286,5 +326,9 @@ fn build(b: Builtin) -> Profile {
// The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test); rebuild
// them rather than crash if they ever fail to parse.
Builtin::CoatedCmyk => Profile::parse(COATED_CMYK_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()),
// The fit needs the measurement data, which only the tests have; the `fogra39` tests
// check that the shipped bytes parse, so the synthetic coated profile is a fallback
// that does not happen.
Builtin::CoatedFogra39 => Profile::parse(COATED_FOGRA39_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()),
}
}
+2 -1
View File
@@ -8,7 +8,8 @@
//! [`Intent`] and optional black point compensation, and converts 8/16-bit and float pixel
//! buffers through precomputed tables (tetrahedral 3D/4D interpolation, rayon on native).
//! * [`builtin`] ships sRGB, Display P3, Adobe RGB (1998)-compatible, ProPhoto-compatible,
//! linear sRGB, Rec. 2020, Gray 2.2, sGray, Lab D50 and a synthetic coated CMYK profile.
//! linear sRGB, Rec. 2020, Gray 2.2, sGray, Lab D50, a synthetic coated CMYK profile and a
//! Coated FOGRA39 profile fitted to Fogra's characterisation data.
//! * [`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,
+68
View File
@@ -154,6 +154,58 @@ pub fn delta_e76(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// CIEDE2000 colour difference (CIE 142-2001, kL = kC = kH = 1).
pub fn delta_e2000(a: [f64; 3], b: [f64; 3]) -> f64 {
use std::f64::consts::PI;
let (l1, a1, b1) = (a[0], a[1], a[2]);
let (l2, a2, b2) = (b[0], b[1], b[2]);
let c_bar = (a1.hypot(b1) + a2.hypot(b2)) / 2.0;
let c7 = c_bar.powi(7);
let g = 0.5 * (1.0 - (c7 / (c7 + 25f64.powi(7))).sqrt());
let (a1p, a2p) = (a1 * (1.0 + g), a2 * (1.0 + g));
let (c1p, c2p) = (a1p.hypot(b1), a2p.hypot(b2));
let hue = |b: f64, a: f64| if b == 0.0 && a == 0.0 { 0.0 } else { b.atan2(a).rem_euclid(2.0 * PI) };
let (h1p, h2p) = (hue(b1, a1p), hue(b2, a2p));
let dl = l2 - l1;
let dc = c2p - c1p;
let dh = if c1p * c2p == 0.0 {
0.0
} else {
let d = h2p - h1p;
if d > PI {
d - 2.0 * PI
} else if d < -PI {
d + 2.0 * PI
} else {
d
}
};
let dhh = 2.0 * (c1p * c2p).sqrt() * (dh / 2.0).sin();
let l_bar = (l1 + l2) / 2.0;
let cp_bar = (c1p + c2p) / 2.0;
let hp_bar = if c1p * c2p == 0.0 {
h1p + h2p
} else if (h1p - h2p).abs() <= PI {
(h1p + h2p) / 2.0
} else if h1p + h2p < 2.0 * PI {
(h1p + h2p + 2.0 * PI) / 2.0
} else {
(h1p + h2p - 2.0 * PI) / 2.0
};
let t = 1.0 - 0.17 * (hp_bar - PI / 6.0).cos() + 0.24 * (2.0 * hp_bar).cos() + 0.32 * (3.0 * hp_bar + PI / 30.0).cos()
- 0.20 * (4.0 * hp_bar - 63f64.to_radians()).cos();
let d_theta = 30f64.to_radians() * (-((hp_bar.to_degrees() - 275.0) / 25.0).powi(2)).exp();
let cp7 = cp_bar.powi(7);
let rc = 2.0 * (cp7 / (cp7 + 25f64.powi(7))).sqrt();
let l50 = (l_bar - 50.0).powi(2);
let sl = 1.0 + 0.015 * l50 / (20.0 + l50).sqrt();
let sc = 1.0 + 0.045 * cp_bar;
let sh = 1.0 + 0.015 * cp_bar * t;
let rt = -(2.0 * d_theta).sin() * rc;
let (x, y, z) = (dl / sl, dc / sc, dhh / sh);
(x * x + y * y + z * z + rt * y * z).sqrt()
}
/// Quantizes to ICC s15Fixed16Number and back (what a profile can actually store).
pub fn s15f16_round(v: f64) -> f64 {
(v * 65536.0).round() / 65536.0
@@ -180,6 +232,22 @@ mod tests {
assert!((w[0] - 100.0).abs() < 1e-12 && w[1].abs() < 1e-12 && w[2].abs() < 1e-12);
}
#[test]
fn delta_e2000_matches_sharma_reference_pairs() {
// Sharma, Wu & Dalal (2005), test data pairs 1, 7, 17, 25 and 34.
let pairs = [
([50.0, 2.6772, -79.7751], [50.0, 0.0, -82.7485], 2.0425),
([50.0, 0.0, 0.0], [50.0, -1.0, 2.0], 2.3669),
([50.0, 2.5, 0.0], [73.0, 25.0, -18.0], 27.1492),
([60.2574, -34.0099, 36.2677], [60.4626, -34.1751, 39.4387], 1.2644),
([22.7233, 20.0904, -46.6940], [23.0331, 14.9730, -42.5619], 2.0373),
];
for (a, b, want) in pairs {
assert!((delta_e2000(a, b) - want).abs() < 1e-4, "{a:?} {b:?}: {}", delta_e2000(a, b));
assert!((delta_e2000(b, a) - want).abs() < 1e-4);
}
}
#[test]
fn bradford_maps_white() {
let d65 = xy_to_xyz(D65_XY);
+71 -14
View File
@@ -21,13 +21,17 @@
//!
//! The model is plausible for coated offset, not a measured characterisation: use a real
//! profile from your print provider for production print.
//!
//! [`cmyk_profile_from_model`] builds the same tables from any [`Forward`] model, inverting its
//! sampled AToB table; the Coated FOGRA39 built-in is made that way from a model fitted to
//! Fogra's measurements (`tests/fogra39.rs`).
use crate::Intent;
use crate::clut::Clut;
use crate::curve::Curve;
use crate::math;
use crate::pipeline::Stage;
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
use crate::{CmsError, Intent};
pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
@@ -35,6 +39,8 @@ pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medi
#[derive(Clone, Debug)]
pub struct CmykParams {
pub description: String,
/// Copyright tag text.
pub copyright: String,
/// Total area coverage limit (3.0 = 300 %).
pub tac: f64,
/// Gray component (min of C, M, Y of the K-free solution) where black generation starts.
@@ -54,6 +60,7 @@ impl Default for CmykParams {
fn default() -> Self {
CmykParams {
description: DESCRIPTION.into(),
copyright: crate::builtin::COPYRIGHT.into(),
tac: 3.0,
gcr_start: 0.2,
k_max: 0.95,
@@ -142,9 +149,33 @@ impl CmykModel {
}
}
/// A printer model: CMYK coverages (each in `[0, 1]`) → ICC-relative Lab (paper = L* 100).
/// The separation solves and the gamut boundary invert it.
pub trait Forward: Sync {
fn lab(&self, cmyk: [f64; 4]) -> [f64; 3];
}
impl Forward for CmykModel {
fn lab(&self, cmyk: [f64; 4]) -> [f64; 3] {
CmykModel::lab(self, cmyk)
}
}
/// A forward model read from a sampled AToB table: 4 inputs, legacy (`lut16`) Lab outputs, as
/// [`sample_a2b`] makes it.
struct TableForward(Clut);
impl Forward for TableForward {
fn lab(&self, cmyk: [f64; 4]) -> [f64; 3] {
let mut o = [0.0f32; 3];
self.0.eval(&cmyk.map(|v| v.clamp(0.0, 1.0) as f32), &mut o);
decode_v2(o.map(f64::from))
}
}
/// Bounded least-squares solve for C, M, Y given K, minimising ΔE76 to `target`, subject to
/// `C + M + Y ≤ limit` (projected Levenberg–Marquardt with a numerical Jacobian).
fn solve_cmy(model: &CmykModel, target: [f64; 3], k: f64, limit: f64, seeds: &[[f64; 3]]) -> ([f64; 3], f64) {
fn solve_cmy<F: Forward + ?Sized>(model: &F, target: [f64; 3], k: f64, limit: f64, seeds: &[[f64; 3]]) -> ([f64; 3], f64) {
let project = |mut x: [f64; 3]| {
for _ in 0..4 {
for v in &mut x {
@@ -238,7 +269,7 @@ fn solve_cmy(model: &CmykModel, target: [f64; 3], k: f64, limit: f64, seeds: &[[
const SEEDS: [[f64; 3]; 5] = [[0.3, 0.3, 0.3], [0.05, 0.05, 0.05], [0.8, 0.8, 0.8], [0.9, 0.2, 0.2], [0.2, 0.9, 0.9]];
/// Colorimetric separation of a relative Lab target: GCR black generation then a CMY solve.
pub fn separate(model: &CmykModel, p: &CmykParams, target: [f64; 3], warm: Option<[f64; 4]>) -> ([f64; 4], f64) {
pub fn separate<F: Forward + ?Sized>(model: &F, p: &CmykParams, target: [f64; 3], warm: Option<[f64; 4]>) -> ([f64; 4], f64) {
let mut seeds: Vec<[f64; 3]> = warm.map(|w| vec![[w[0], w[1], w[2]]]).unwrap_or_default();
seeds.extend_from_slice(&SEEDS);
let (cmy0, _) = solve_cmy(model, target, 0.0, 3.0, &seeds);
@@ -259,7 +290,7 @@ struct Gbd {
}
impl Gbd {
fn new(model: &CmykModel, p: &CmykParams) -> Self {
fn new<F: Forward + ?Sized>(model: &F, p: &CmykParams) -> Self {
let (l_bins, h_bins) = (41usize, 72usize);
let mut cmax = vec![0.0f64; l_bins * h_bins];
let n = 21;
@@ -373,8 +404,8 @@ fn par_map<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
(0..n).map(f).collect()
}
fn b2a_table(model: &CmykModel, p: &CmykParams, gbd: Option<&Gbd>) -> Clut {
let n = p.grid_b2a;
fn b2a_table<F: Forward + ?Sized>(model: &F, p: &CmykParams, gbd: Option<&Gbd>) -> Clut {
let n = p.grid_b2a.max(2);
let nodes = n * n * n;
let vals = par_map(n * n, |row| {
// One (L, a) row along b, warm-starting each solve from its neighbour.
@@ -411,14 +442,40 @@ fn lut16(clut: Clut) -> Lut {
/// Generates a synthetic CMYK profile.
pub fn cmyk_profile(p: &CmykParams) -> Profile {
let model = CmykModel::coated(p.tvi);
let g = p.grid_a2b;
let a2b = Clut::sample(vec![g; 4], 3, |inp, out| {
let a2b = sample_a2b(&model, p.grid_a2b);
lut_profile(&model, a2b, model.paper, p)
}
/// Samples a forward model into an AToB table with `grid`⁴ nodes (at least 2⁴) and legacy Lab
/// encoding.
fn sample_a2b<F: Forward + ?Sized>(model: &F, grid: usize) -> Clut {
Clut::sample(vec![grid.max(2); 4], 3, |inp, out| {
let lab = model.lab([inp[0] as f64, inp[1] as f64, inp[2] as f64, inp[3] as f64]);
out.copy_from_slice(&encode_v2(lab));
});
let gbd = Gbd::new(&model, p);
let colorimetric = b2a_table(&model, p, None);
let perceptual = b2a_table(&model, p, Some(&gbd));
})
}
/// A CMYK profile of any forward model on paper with absolute XYZ `paper`: the AToB table
/// samples `model` on `p.grid_a2b`⁴ nodes, and the BToA tables invert that table rather than the
/// model, so Lab → CMYK → Lab round trips through the profile are as close as the separation
/// allows. Grids below 2 nodes and a zero, negative or non-finite `paper` are refused with
/// [`CmsError::Invalid`].
pub fn cmyk_profile_from_model<F: Forward + ?Sized>(model: &F, paper: [f64; 3], p: &CmykParams) -> Result<Profile, CmsError> {
if p.grid_a2b < 2 || p.grid_b2a < 2 {
return Err(CmsError::Invalid(format!("CMYK profile grids {}⁴/{}³ need at least 2 nodes", p.grid_a2b, p.grid_b2a)));
}
if !paper.iter().all(|v| v.is_finite() && *v > 0.0) {
return Err(CmsError::Invalid(format!("paper XYZ {paper:?}")));
}
let a2b = sample_a2b(model, p.grid_a2b);
let forward = TableForward(a2b.clone());
Ok(lut_profile(&forward, a2b, paper, p))
}
fn lut_profile<F: Forward + ?Sized>(model: &F, a2b: Clut, paper: [f64; 3], p: &CmykParams) -> Profile {
let gbd = Gbd::new(model, p);
let colorimetric = b2a_table(model, p, None);
let perceptual = b2a_table(model, p, Some(&gbd));
let a2b = lut16(a2b);
let (b0, b1) = (lut16(perceptual), lut16(colorimetric));
Profile {
@@ -428,8 +485,8 @@ pub fn cmyk_profile(p: &CmykParams) -> Profile {
pcs: Pcs::Lab,
rendering_intent: Intent::Perceptual,
description: p.description.clone(),
copyright: crate::builtin::COPYRIGHT.into(),
white_point: model.paper.map(math::s15f16_round),
copyright: p.copyright.clone(),
white_point: paper.map(math::s15f16_round),
black_point: None,
chad: None,
matrix: None,
+1
View File
@@ -0,0 +1 @@
FOGRA39.txt -text
File diff suppressed because it is too large. Load diff
+522
View File
@@ -0,0 +1,522 @@
//! The Coated FOGRA39 built-in profile: accuracy against Fogra's FOGRA39 characterisation data
//! (`tests/data/FOGRA39.txt`), conversions to sRGB against reference values, and its generator:
//!
//! ```sh
//! CRAFT_CMS_REGEN_PROFILES=1 cargo test -p craft-cms --release --test fogra39
//! ```
//!
//! The generator fits a Yule–Nielsen modified Neugebauer model (free 16 overprints, per-ink
//! piecewise-linear tone curves, free n) to all 1617 patches by least squares, corrects the
//! remaining error with a Gaussian-kernel ridge regression of the Lab residuals over CMYK, and
//! samples the result into the AToB table. The BToA tables invert that table
//! ([`synth::cmyk_profile_from_model`]).
use craft_cms::math::{self, delta_e2000};
use craft_cms::synth::{self, CmykParams, Forward};
use craft_cms::{Builtin, Intent, Profile, Transform};
const DATA: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/FOGRA39.txt");
const PROFILE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/profiles/craft-coated-fogra39.icc");
#[derive(Clone)]
struct Patch {
cmyk: [f64; 4],
/// Absolute Lab (D50, 2°) as measured.
lab: [f64; 3],
}
/// The CGATS patches: `SAMPLE_ID CMYK_C CMYK_M CMYK_Y CMYK_K XYZ_X XYZ_Y XYZ_Z LAB_L LAB_A LAB_B`.
fn patches() -> Vec<Patch> {
let text = std::fs::read_to_string(DATA).unwrap();
let mut lines = text.lines().map(str::trim);
let fields: Vec<&str> = lines.by_ref().skip_while(|l| *l != "BEGIN_DATA_FORMAT").nth(1).unwrap().split_whitespace().collect();
let col = |name: &str| fields.iter().position(|f| *f == name).unwrap();
let cols = ["CMYK_C", "CMYK_M", "CMYK_Y", "CMYK_K", "LAB_L", "LAB_A", "LAB_B"].map(col);
let out: Vec<Patch> = lines
.skip_while(|l| *l != "BEGIN_DATA")
.skip(1)
.take_while(|l| *l != "END_DATA")
.map(|l| {
let v: Vec<f64> = l.split_whitespace().map(|t| t.parse().unwrap()).collect();
Patch { cmyk: std::array::from_fn(|i| v[cols[i]] / 100.0), lab: std::array::from_fn(|i| v[cols[4 + i]]) }
})
.collect();
assert_eq!(out.len(), 1617);
out
}
/// Absolute XYZ of the paper (the 0/0/0/0 patch).
fn paper(p: &[Patch]) -> [f64; 3] {
let w = p.iter().find(|p| p.cmyk == [0.0; 4]).unwrap();
math::lab_to_xyz(w.lab, math::D50)
}
/// ICC-relative Lab (paper → L* 100) of absolute XYZ.
fn relative(xyz: [f64; 3], paper: [f64; 3]) -> [f64; 3] {
math::xyz_to_lab(std::array::from_fn(|i| xyz[i] * math::D50[i] / paper[i]), math::D50)
}
/// The measured colours as ICC-relative Lab.
fn relative_targets(p: &[Patch]) -> Vec<[f64; 3]> {
let w = paper(p);
p.iter().map(|p| relative(math::lab_to_xyz(p.lab, math::D50), w)).collect()
}
struct Stats {
mean: f64,
p95: f64,
max: f64,
worst: [f64; 4],
}
fn stats(p: &[Patch], lab_of: impl Fn([f64; 4]) -> [f64; 3]) -> Stats {
let want = relative_targets(p);
let mut de: Vec<(f64, [f64; 4])> = p.iter().zip(&want).map(|(p, w)| (delta_e2000(lab_of(p.cmyk), *w), p.cmyk)).collect();
let mean = de.iter().map(|d| d.0).sum::<f64>() / de.len() as f64;
de.sort_by(|a, b| a.0.total_cmp(&b.0));
let p95 = de[(de.len() * 95).div_ceil(100) - 1].0;
let (max, worst) = de[de.len() - 1];
Stats { mean, p95, max, worst }
}
/// Relative Lab through a profile's colorimetric AToB.
fn profile_lab(profile: &Profile) -> impl Fn([f64; 4]) -> [f64; 3] {
let t = Transform::new(profile, Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap();
move |c| {
let mut o = [0.0f32; 3];
t.eval(&c.map(|v| v as f32), &mut o);
[o[0] as f64 * 100.0, o[1] as f64 * 255.0 - 128.0, o[2] as f64 * 255.0 - 128.0]
}
}
// --- Fit ------------------------------------------------------------------------------------
/// Tone curve knots at 10 %, 20 % … 90 % coverage.
const KNOTS: usize = 9;
const PARAMS: usize = 1 + 48 + 4 * KNOTS;
/// Yule–Nielsen modified Neugebauer model: XYZ = (Σ Demichel weight · Pᵢ^(1/n))ⁿ over the 16
/// overprints, with effective coverages from per-ink piecewise-linear tone curves.
#[derive(Clone)]
struct Model {
n: f64,
/// Overprint XYZ^(1/n), indexed by ink bit mask (C=1, M=2, Y=4, K=8).
prim_n: [[f64; 3]; 16],
tone: [[f64; KNOTS]; 4],
}
impl Model {
fn initial(p: &[Patch]) -> Model {
let n = 2.0;
let prim_n = std::array::from_fn(|m| {
let cmyk: [f64; 4] = std::array::from_fn(|i| if m & (1 << i) != 0 { 1.0 } else { 0.0 });
let solid = p.iter().find(|p| p.cmyk == cmyk).unwrap();
math::lab_to_xyz(solid.lab, math::D50).map(|v| v.powf(1.0 / n))
});
let tone = std::array::from_fn(|_| {
std::array::from_fn(|k| {
let v = (k + 1) as f64 / 10.0;
v + 0.6 * v * (1.0 - v)
})
});
Model { n, prim_n, tone }
}
fn coverage(t: &[f64; KNOTS], v: f64) -> f64 {
let p = v.clamp(0.0, 1.0) * (KNOTS + 1) as f64;
let i = (p.floor() as usize).min(KNOTS);
let at = |k: usize| match k {
0 => 0.0,
k if k > KNOTS => 1.0,
k => t[k - 1],
};
at(i) + (at(i + 1) - at(i)) * (p - i as f64)
}
fn xyz(&self, cmyk: [f64; 4]) -> [f64; 3] {
let a: [f64; 4] = std::array::from_fn(|i| Model::coverage(&self.tone[i], cmyk[i]));
let mut s = [0.0; 3];
for (m, q) in self.prim_n.iter().enumerate() {
let w: f64 = (0..4).map(|i| if m & (1 << i) != 0 { a[i] } else { 1.0 - a[i] }).product();
for (acc, v) in s.iter_mut().zip(q) {
*acc += w * v;
}
}
s.map(|v| v.max(0.0).powf(self.n))
}
fn params(&self) -> Vec<f64> {
let mut v = vec![self.n];
v.extend(self.prim_n.iter().flatten());
v.extend(self.tone.iter().flatten());
v
}
fn from_params(v: &[f64]) -> Model {
Model {
n: v[0],
prim_n: std::array::from_fn(|m| std::array::from_fn(|i| v[1 + m * 3 + i])),
tone: std::array::from_fn(|c| std::array::from_fn(|k| v[49 + c * KNOTS + k])),
}
}
}
/// Solves `a x = b` for symmetric positive definite `a` (row-major `n × n`) and `cols`
/// right-hand sides (`b` row-major `n × cols`), in place in `b`.
fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64], cols: usize) {
for j in 0..n {
let mut d = a[j * n + j];
for k in 0..j {
d -= a[j * n + k] * a[j * n + k];
}
assert!(d > 0.0, "matrix is not positive definite");
let d = d.sqrt();
a[j * n + j] = d;
for i in j + 1..n {
let mut s = a[i * n + j];
for k in 0..j {
s -= a[i * n + k] * a[j * n + k];
}
a[i * n + j] = s / d;
}
}
for c in 0..cols {
for i in 0..n {
let mut s = b[i * cols + c];
for k in 0..i {
s -= a[i * n + k] * b[k * cols + c];
}
b[i * cols + c] = s / a[i * n + i];
}
for i in (0..n).rev() {
let mut s = b[i * cols + c];
for k in i + 1..n {
s -= a[k * n + i] * b[k * cols + c];
}
b[i * cols + c] = s / a[i * n + i];
}
}
}
/// Levenberg–Marquardt on the absolute Lab error of every patch.
fn fit_model(p: &[Patch]) -> Model {
let resid = |x: &[f64]| -> Vec<f64> {
let m = Model::from_params(x);
p.iter()
.flat_map(|p| {
let l = math::xyz_to_lab(m.xyz(p.cmyk), math::D50);
[l[0] - p.lab[0], l[1] - p.lab[1], l[2] - p.lab[2]]
})
.collect()
};
let cost = |r: &[f64]| r.iter().map(|v| v * v).sum::<f64>();
let mut x = Model::initial(p).params();
let mut r = resid(&x);
let mut f = cost(&r);
let mut lambda = 1e-3;
for _ in 0..200 {
let jac: Vec<Vec<f64>> = (0..PARAMS)
.map(|j| {
let h = 1e-6 * x[j].abs().max(1e-2);
let mut xp = x.clone();
xp[j] += h;
resid(&xp).iter().zip(&r).map(|(a, b)| (a - b) / h).collect()
})
.collect();
let mut jtj = vec![0.0; PARAMS * PARAMS];
let mut g = vec![0.0; PARAMS];
for a in 0..PARAMS {
for b in a..PARAMS {
let s: f64 = jac[a].iter().zip(&jac[b]).map(|(u, v)| u * v).sum();
jtj[a * PARAMS + b] = s;
jtj[b * PARAMS + a] = s;
}
g[a] = -jac[a].iter().zip(&r).map(|(u, v)| u * v).sum::<f64>();
}
let mut improved = false;
for _ in 0..10 {
let mut m = jtj.clone();
for d in 0..PARAMS {
m[d * PARAMS + d] += lambda * (1.0 + jtj[d * PARAMS + d]);
}
let mut step = g.clone();
cholesky_solve(&mut m, PARAMS, &mut step, 1);
let xn: Vec<f64> = x.iter().zip(&step).map(|(a, b)| a + b).collect();
let rn = resid(&xn);
let fnew = cost(&rn);
if fnew < f {
improved = (f - fnew) > 1e-9 * f;
(x, r, f) = (xn, rn, fnew);
lambda = (lambda * 0.3).max(1e-12);
break;
}
lambda *= 10.0;
}
if !improved {
break;
}
}
Model::from_params(&x)
}
/// Kernel ridge regression of Lab residuals over CMYK with a Gaussian kernel.
struct Correction {
centers: Vec<[f64; 4]>,
weights: Vec<[f64; 3]>,
inv_2s2: f64,
}
impl Correction {
fn fit(centers: Vec<[f64; 4]>, residuals: &[[f64; 3]], sigma: f64, lambda: f64) -> Correction {
let n = centers.len();
let inv_2s2 = 1.0 / (2.0 * sigma * sigma);
let mut k = vec![0.0; n * n];
for i in 0..n {
for j in 0..n {
k[i * n + j] = kernel(centers[i], centers[j], inv_2s2) + if i == j { lambda } else { 0.0 };
}
}
let mut b: Vec<f64> = residuals.iter().flatten().copied().collect();
cholesky_solve(&mut k, n, &mut b, 3);
let weights = b.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
Correction { centers, weights, inv_2s2 }
}
fn eval(&self, x: [f64; 4]) -> [f64; 3] {
let mut s = [0.0; 3];
for (c, w) in self.centers.iter().zip(&self.weights) {
let k = kernel(*c, x, self.inv_2s2);
for i in 0..3 {
s[i] += k * w[i];
}
}
s
}
}
fn kernel(a: [f64; 4], b: [f64; 4], inv_2s2: f64) -> f64 {
let d2: f64 = (0..4).map(|i| (a[i] - b[i]).powi(2)).sum();
(-d2 * inv_2s2).exp()
}
/// The fitted forward model: Neugebauer model plus residual correction, ICC-relative Lab.
struct Fitted {
model: Model,
paper: [f64; 3],
correction: Correction,
}
impl Forward for Fitted {
fn lab(&self, cmyk: [f64; 4]) -> [f64; 3] {
let l = relative(self.model.xyz(cmyk), self.paper);
let c = self.correction.eval(cmyk);
[l[0] + c[0], l[1] + c[1], l[2] + c[2]]
}
}
/// Fits the model and its correction (σ = 0.2, λ = 0.01) to the patches `p`, which include the
/// paper patch.
fn fitted(p: &[Patch]) -> Fitted {
let model = fit_model(p);
let paper = model.xyz([0.0; 4]);
let want = relative_targets(p);
let residuals: Vec<[f64; 3]> = p
.iter()
.zip(&want)
.map(|(p, w)| {
let l = relative(model.xyz(p.cmyk), paper);
[w[0] - l[0], w[1] - l[1], w[2] - l[2]]
})
.collect();
let correction = Correction::fit(p.iter().map(|p| p.cmyk).collect(), &residuals, 0.2, 0.01);
Fitted { model, paper, correction }
}
#[test]
fn regen_coated_fogra39() {
if std::env::var_os("CRAFT_CMS_REGEN_PROFILES").is_none() {
return;
}
let t = std::time::Instant::now();
let p = patches();
let f = fitted(&p);
let s = stats(&p, |c| relative(f.model.xyz(c), f.paper));
eprintln!("model n {:.3}: mean ΔE00 {:.2} p95 {:.2} max {:.2} at {:?}", f.model.n, s.mean, s.p95, s.max, s.worst);
let params = CmykParams {
description: Builtin::CoatedFogra39.description().into(),
copyright: craft_cms::builtin::COATED_FOGRA39_COPYRIGHT.into(),
tac: 3.3,
grid_b2a: 33,
..CmykParams::default()
};
let profile = synth::cmyk_profile_from_model(&f, paper(&p), &params).unwrap();
let bytes = profile.to_bytes();
std::fs::write(PROFILE, &*bytes).unwrap();
eprintln!("wrote {} bytes in {:?}", bytes.len(), t.elapsed());
}
fn fogra39() -> &'static Profile {
let p = Builtin::CoatedFogra39.profile();
assert_eq!(p.description, Builtin::CoatedFogra39.description(), "the shipped profile parses");
p
}
#[test]
fn fit_predicts_held_out_patches() {
// Fit to 90 % of the patches, then compare the model with the other 10 %. The 16 solid
// overprints stay in the fit, which starts from them.
let all = patches();
let held = |i: usize, p: &Patch| i % 10 == 3 && !p.cmyk.iter().all(|v| *v == 0.0 || *v == 1.0);
let train: Vec<Patch> = all.iter().enumerate().filter(|(i, p)| !held(*i, p)).map(|(_, p)| p.clone()).collect();
let test: Vec<Patch> = all.iter().enumerate().filter(|(i, p)| held(*i, p)).map(|(_, p)| p.clone()).collect();
let f = fitted(&train);
let w = paper(&all);
let mut de: Vec<f64> = test.iter().map(|p| delta_e2000(f.lab(p.cmyk), relative(math::lab_to_xyz(p.lab, math::D50), w))).collect();
de.sort_by(f64::total_cmp);
let mean = de.iter().sum::<f64>() / de.len() as f64;
eprintln!("hold-out ({} patches): mean ΔE00 {mean:.2} p95 {:.2} max {:.2}", de.len(), de[de.len() * 95 / 100], de[de.len() - 1]);
assert!(mean <= 0.2, "hold-out mean {mean}");
}
#[test]
fn model_profiles_refuse_bad_parameters() {
let paper = math::lab_to_xyz([95.0, 0.0, -2.0], math::D50);
for (a2b, b2a) in [(0, 21), (1, 21), (11, 0), (11, 1)] {
let params = CmykParams { grid_a2b: a2b, grid_b2a: b2a, ..CmykParams::default() };
assert!(synth::cmyk_profile_from_model(&Flat, paper, &params).is_err(), "{a2b} {b2a}");
}
let small = CmykParams { grid_a2b: 2, grid_b2a: 2, ..CmykParams::default() };
assert!(synth::cmyk_profile_from_model(&Flat, [0.0, 1.0, 1.0], &small).is_err());
assert!(synth::cmyk_profile_from_model(&Flat, [f64::NAN, 1.0, 1.0], &small).is_err());
assert!(synth::cmyk_profile_from_model(&Flat, paper, &small).is_ok());
}
/// Paper white everywhere.
struct Flat;
impl Forward for Flat {
fn lab(&self, _: [f64; 4]) -> [f64; 3] {
[100.0, 0.0, 0.0]
}
}
#[test]
fn fits_the_fogra39_data() {
let p = patches();
let s = stats(&p, profile_lab(fogra39()));
eprintln!("Coated FOGRA39 builtin: mean ΔE00 {:.2} p95 {:.2} max {:.2} at {:?}", s.mean, s.p95, s.max, s.worst);
assert!(s.mean <= 0.15 && s.p95 <= 0.35 && s.max <= 0.8, "mean {} p95 {} max {}", s.mean, s.p95, s.max);
let s = stats(&p, profile_lab(Builtin::CoatedCmyk.profile()));
eprintln!("Photocraft coated builtin: mean ΔE00 {:.2} p95 {:.2} max {:.2} at {:?}", s.mean, s.p95, s.max, s.worst);
}
#[test]
fn darkens_with_every_ink() {
let lab = profile_lab(fogra39());
for base in [[0.0; 4], [0.2, 0.2, 0.2, 0.0], [0.6, 0.4, 0.4, 0.2], [0.0, 0.5, 1.0, 0.0], [0.8, 0.7, 0.7, 0.5]] {
for ink in 0..4 {
let mut prev = f64::INFINITY;
for i in 0..=40 {
let mut v = base;
v[ink] = i as f64 / 40.0;
let l = lab(v)[0];
assert!(l < prev + 0.2, "{base:?} ink {ink} at {}: L* {l} after {prev}", v[ink]);
prev = l;
}
}
}
}
#[test]
fn in_gamut_lab_round_trips() {
let profile = fogra39();
let lab = Builtin::LabD50.profile();
let to_cmyk = Transform::new(lab, profile, Intent::RelativeColorimetric, false).unwrap();
let to_lab = profile_lab(profile);
// Targets: the profile's own colours of the patches inside the gamut (no solid ink, well
// within the 330 % ink limit). Gamut-edge colours with black (e.g. C100 K100) come back
// up to ~7 ΔE00 off, as with the Photocraft profile: black generation fixes K before the
// CMY solve.
let mut de: Vec<f64> = patches()
.iter()
.filter(|p| p.cmyk.iter().all(|v| *v <= 0.85) && p.cmyk.iter().sum::<f64>() <= 2.8)
.map(|p| {
let target = to_lab(p.cmyk);
let enc = [(target[0] / 100.0) as f32, ((target[1] + 128.0) / 255.0) as f32, ((target[2] + 128.0) / 255.0) as f32];
let mut c = [0.0f32; 4];
to_cmyk.eval(&enc, &mut c);
assert!(c.iter().map(|v| *v as f64).sum::<f64>() <= 3.3 + 0.02, "{target:?} -> {c:?}");
delta_e2000(to_lab(c.map(f64::from)), target)
})
.collect();
de.sort_by(f64::total_cmp);
let mean = de.iter().sum::<f64>() / de.len() as f64;
let (p95, max) = (de[de.len() * 95 / 100], de[de.len() - 1]);
eprintln!("Lab → CMYK → Lab over {} colours: mean ΔE00 {mean:.2} p95 {p95:.2} max {max:.2}", de.len());
assert!(mean < 0.5 && p95 < 1.5 && max < 3.0, "mean {mean} p95 {p95} max {max}");
}
/// Reference sRGB values for CMYK in Coated FOGRA39 (relative colorimetric with black point
/// compensation).
const REFERENCE_SRGB: [([f64; 4], [u8; 3]); 8] = [
([0.0, 0.5, 1.0, 0.0], [243, 146, 0]),
([0.0, 0.6, 1.0, 0.0], [239, 125, 0]),
([1.0, 0.0, 0.0, 0.0], [0, 159, 227]),
([0.0, 1.0, 0.0, 0.0], [229, 0, 125]),
([0.0, 0.0, 1.0, 0.0], [255, 237, 0]),
([0.0, 0.0, 0.0, 1.0], [29, 29, 27]),
([1.0, 1.0, 0.0, 0.0], [49, 39, 131]),
([0.0, 1.0, 1.0, 0.0], [227, 6, 19]),
];
fn to_srgb(profile: &Profile) -> impl Fn([f64; 4]) -> [u8; 3] {
let t = Transform::new(profile, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap();
move |c| {
let mut o = [0.0f32; 3];
t.eval(&c.map(|v| v as f32), &mut o);
o.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
}
}
fn srgb_lab(rgb: [u8; 3]) -> [f64; 3] {
let t = Transform::new(Builtin::Srgb.profile(), Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap();
let mut o = [0.0f32; 3];
t.eval(&rgb.map(|v| v as f32 / 255.0), &mut o);
[o[0] as f64 * 100.0, o[1] as f64 * 255.0 - 128.0, o[2] as f64 * 255.0 - 128.0]
}
#[test]
fn matches_reference_srgb_values() {
let ours = to_srgb(fogra39());
let photocraft = to_srgb(Builtin::CoatedCmyk.profile());
let mut worst = 0.0f64;
for (cmyk, want) in REFERENCE_SRGB {
let (got, pc) = (ours(cmyk), photocraft(cmyk));
let de = delta_e2000(srgb_lab(got), srgb_lab(want));
let de_pc = delta_e2000(srgb_lab(pc), srgb_lab(want));
eprintln!("{cmyk:?}: reference {want:?} | FOGRA39 {got:?} ΔE00 {de:.2} | Photocraft {pc:?} ΔE00 {de_pc:.2}");
worst = worst.max(de);
}
assert!(worst <= 1.0, "worst ΔE00 {worst}");
}
#[test]
fn builtins_by_document_profile_name() {
for name in [
"Coated FOGRA39 (ISO 12647-2:2004)",
"Coated FOGRA39",
"ISO Coated v2 (ECI)",
"ISO Coated v2 300% (ECI)",
"iso coated V2 300 % (eci)",
"Coated FOGRA39 (fitted, craft-cms)",
] {
assert_eq!(Builtin::from_profile_name(name), Some(Builtin::CoatedFogra39), "{name}");
}
for b in Builtin::ALL {
assert_eq!(Builtin::from_profile_name(b.description()), Some(b), "{b:?}");
}
assert_eq!(Builtin::from_profile_name(" srgb iec61966-2.1 "), Some(Builtin::Srgb));
for name in ["U.S. Web Coated (SWOP) v2", "Coated FOGRA51 (PSO)", "PSO Coated v3", ""] {
assert_eq!(Builtin::from_profile_name(name), None, "{name}");
}
}