mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-13 15:09:09 +00:00
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:
1 parent
dd9e3f1bc3
commit
f97ed953f4
12 files changed
+2424
-27
No files matched your search
@@ -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)) |
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
FOGRA39.txt -text
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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), ¶ms).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, ¶ms).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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user