Add craft-cms: ICC colour management extracted from PhotoCraft

Move PhotoCraft's crates/cms (storytold/photocraft@b3222ee8e7)
into crates/cms as craft-cms: ICC v2/v4 profile parsing and writing, transforms, rendering
intents, black point compensation, soft proofing, gamut checks, synthetic CMYK, 3D LUTs, LUT
files and built-in profiles, with all tests.

The Bradford inverse and the built-in RGB colorant matrices are computed in constants, so the
build rejects a singular matrix and the two expect calls are gone; the values are bit-identical
to the runtime results. Built-in profile descriptions and copyright text keep their PhotoCraft
wording so the profile bytes do not change. rayon joins the workspace dependencies.

Refs #36

Co-authored-by: Alex Gorevski <agorevski@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Elvis33LE <67311580+Elvis33LE@users.noreply.github.com>
Co-authored-by: F4LCON <107556180+itsF4LCON@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com>
Co-authored-by: Shehan Kavishka <kavishkashehan726@gmail.com>
Co-authored-by: Valentin Klein <vangel1976@googlemail.com>
Co-authored-by: ljkx <177671075+ljkx@users.noreply.github.com>
Co-authored-by: storybored379 <storybored379@gmail.com>
Co-authored-by: wstock <w.stock@yahoo.com>
This commit is contained in:
24 files changed
+5533 -2

No files matched your search

+3 -1
View File
@@ -4,4 +4,6 @@ Third-party assets bundled in this repository (fonts, icons, images, test data),
author, source and license. Library code and original assets are under the repository license
(MIT OR Apache-2.0, see [NOTICE](NOTICE)).
None yet.
| 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 |
Generated
+47
View File
@@ -521,6 +521,14 @@ dependencies = [
"rustix 1.1.5",
]
[[package]]
name = "craft-cms"
version = "0.1.0"
dependencies = [
"moxcms",
"rayon",
]
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -596,6 +604,25 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
@@ -2823,6 +2850,26 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "read-fonts"
version = "0.41.0"
+2
View File
@@ -12,11 +12,13 @@ repository = "https://github.com/storytold/craft-libs"
[workspace.dependencies]
# internal: add each crate here as `craft-<name> = { path = "crates/<name>" }`
craft-atomic = { path = "crates/atomic" }
craft-cms = { path = "crates/cms" }
craft-print = { path = "crates/print" }
craft-quit = { path = "crates/quit" }
craft-settings = { path = "crates/settings" }
craft-ui = { path = "crates/ui" }
egui = { version = "0.36", default-features = false }
rayon = "1"
[workspace.lints.rust]
unsafe_code = "forbid"
+8 -1
View File
@@ -8,7 +8,7 @@ Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
None: every bundled asset is original to craft-libs.
crates/cms/profiles/photocraft-coated-cmyk.icc: CC0-1.0, generated by crates/cms (see below).
Source provenance
@@ -62,3 +62,10 @@ PrintTicket and its driver-paper preference):
https://github.com/storytold/pdfcraft
Copyright (c) 2026 ArtCraft Team and the PdfCraft contributors.
Licensed MIT OR Apache-2.0.
The colour management module in crates/cms is extracted from:
- PhotoCraft, crates/cms, revision b3222ee8e7e6bcb4799c37f904c595f20f9baf5e.
https://github.com/storytold/photocraft
Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors.
Licensed MIT OR Apache-2.0. Its generated ICC profile is dedicated to the public domain
(CC0-1.0); see ATTRIBUTION.md.
+3
View File
@@ -24,6 +24,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
## Libraries
- [`craft-atomic`](crates/atomic/README.md): Crash-safe atomic file writing and replacement with same-directory staging, directory fsync, Apple `F_FULLFSYNC` fallback, transient lock backoff, and fault injection for the Craft suite.
- [`craft-cms`](crates/cms/README.md): pure-Rust ICC v2/v4 colour management: profile parsing
and writing, transforms, rendering intents, black point compensation, soft proofing, gamut
checks, 3D LUTs and built-in profiles (sRGB, Display P3, synthetic coated CMYK and others).
- [`craft-print`](crates/print/README.md): PDF submission to CUPS and Windows, printer discovery,
duplex/copies/colour and driver preferences. PDF generation stays in each app.
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "craft-cms"
version.workspace = true
edition.workspace = true
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"
[lints]
workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = { workspace = true }
[dev-dependencies]
moxcms = "0.8"
+95
View File
@@ -0,0 +1,95 @@
# Craft CMS
`craft-cms` is a pure-Rust ICC colour management module for the Craft suite: profile parsing
and writing, transforms between profiles, rendering intents, black point compensation, soft
proofing, gamut checks, 3D LUTs and a set of built-in profiles. No C, no `unsafe`, and no
dependency on other craft-libs crates. Implemented from the public ICC.1:2010 (v4.3) and
ICC.1:2001-04 (v2) specifications, CIE 15, and Adobe's published black point compensation paper.
Extracted from PhotoCraft's `crates/cms` at
[`b3222ee`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms)
(refs #36).
## API
```rust
use craft_cms::{Builtin, Intent, Profile, Transform};
let src = Profile::parse(&icc_bytes)?; // v2/v4: matrix/TRC, gray TRC, mft1/mft2/mAB/mBA
let dst = Builtin::CoatedCmyk.profile();
let t = Transform::new(&src, dst, Intent::RelativeColorimetric, /*bpc*/ true)?;
t.convert_u8(&rgba8, 4, &mut cmyka8, 5, /*copy alpha*/ true); // also convert_u16 / convert_f32 / apply (in place)
let mut out = [0.0; 4];
t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colour
```
* `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain.
* `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning).
* `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture.
* `transform::cached(src, dst, opts)`: process-wide transform cache.
* `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.
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
a device grid within ΔE 0.5 through both), e.g. Photoshop's sRGB IEC61966-2.1 and the built-in
sRGB. `content_hash()` compares bytes and suits cache keys only.
### Evaluation
* Exact path: a float stage pipeline (curves, matrices, CLUTs, Lab↔XYZ, PCS encodings, BPC /
absolute scaling). Float buffers use it by default (`TransformOptions::precise_float`).
* Integer buffers: matrix/TRC chains use per-code input tables + matrix + output tables indexed
by `x^(1/4)` (accurate near black even for pure gamma curves); chains containing a CLUT are
sampled into a device link (33³ for 3 inputs, 17⁴ for 4, 4096 for 1) evaluated with
tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely
analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon
(native only).
* Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute
colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero,
negative or non-finite white is refused with `CmsError::Invalid`); BPC uses
neutral black points (Y only) mapped linearly in XYZ.
## Built-in profiles (all CC0-1.0, generated by this crate)
The profile descriptions and copyright text keep the PhotoCraft names they were first
published with, so profiles embedded in existing files still match byte for byte.
| id | description |
|---|---|
| `srgb` | sRGB IEC61966-2.1 (Rec. 709 primaries, D65 Bradford-adapted, sRGB TRC) |
| `display-p3` | Display P3 |
| `adobe-rgb-compat` | Adobe RGB (1998)-compatible (published primaries, gamma 563/256) |
| `prophoto-compat` | ProPhoto-compatible (ROMM primaries, D50, gamma 1.8) |
| `linear-srgb` | sRGB primaries, linear |
| `rec2020` | Rec. 2020 primaries, Rec. 709 OETF |
| `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 |
### The CMYK profile
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
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
```sh
CRAFT_CMS_REGEN_PROFILES=1 cargo test -p craft-cms --release --test regen
```
It is a plausible coated-offset profile, not a measured characterisation: use your printer's
profile for production print.
## Not implemented / approximate
* v4.4 `mpet` (multiProcessElements, D2Bx/B2Dx) tags, named colour, device link and abstract
profiles as transform endpoints, `chad`-based adaptation of v2 absolute colorimetry.
* BPC destination black uses the BToA/AToB round trip directly (Adobe's curve fit for
ill-behaved LUT profiles is not implemented); v2/v4 perceptual reference black mixing is not
modelled beyond the PRMG black for v4 LUT profiles.
* Profile ID (MD5) is not computed when writing (left zero, which the spec allows).
Binary file not shown.
+290
View File
@@ -0,0 +1,290 @@
//! Built-in profiles. All are generated by this crate from published colorimetric definitions
//! (primaries, white points, transfer functions) and dedicated to the public domain (CC0-1.0),
//! so documents that embed them carry no licence obligations. See the crate README.
use std::sync::OnceLock;
use crate::Intent;
use crate::curve::{Curve, srgb_trc};
use crate::math::{self, Mat3};
use crate::pipeline::Stage;
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
/// Copyright tag text of every built-in profile. It is part of the profiles' bytes, so it keeps
/// 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.";
/// Identifiers of the built-in profiles.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Builtin {
/// sRGB IEC 61966-2.1 (Rec. 709 primaries, D65, sRGB transfer function).
Srgb,
/// Display P3 (DCI-P3 primaries, D65, sRGB transfer function).
DisplayP3,
/// Adobe RGB (1998)-compatible: its published primaries, D65, gamma 563/256.
AdobeRgbCompat,
/// ProPhoto-compatible: ROMM RGB primaries, D50, gamma 1.8.
ProPhotoCompat,
/// sRGB primaries with a linear transfer function.
LinearSrgb,
/// Rec. 2020 primaries, D65, Rec. 709 transfer function.
Rec2020,
/// Gray, gamma 2.2.
GrayGamma22,
/// Gray with the sRGB transfer function (sGray): gray `v` looks like RGB `(v, v, v)` in sRGB.
SGray,
/// CIE L*a*b* D50 identity (device Lab = PCS Lab, ICC v4 encoding).
LabD50,
/// Synthetic coated-offset CMYK (300 % total ink, medium GCR); see [`crate::synth`].
CoatedCmyk,
}
impl Builtin {
pub const ALL: [Builtin; 10] = [
Builtin::Srgb,
Builtin::DisplayP3,
Builtin::AdobeRgbCompat,
Builtin::ProPhotoCompat,
Builtin::LinearSrgb,
Builtin::Rec2020,
Builtin::GrayGamma22,
Builtin::SGray,
Builtin::LabD50,
Builtin::CoatedCmyk,
];
/// Stable identifier used by commands and settings.
pub fn id(self) -> &'static str {
match self {
Builtin::Srgb => "srgb",
Builtin::DisplayP3 => "display-p3",
Builtin::AdobeRgbCompat => "adobe-rgb-compat",
Builtin::ProPhotoCompat => "prophoto-compat",
Builtin::LinearSrgb => "linear-srgb",
Builtin::Rec2020 => "rec2020",
Builtin::GrayGamma22 => "gray-gamma-2.2",
Builtin::SGray => "sgray",
Builtin::LabD50 => "lab-d50",
Builtin::CoatedCmyk => "coated-cmyk",
}
}
/// Profile description (the `desc` tag).
pub fn description(self) -> &'static str {
match self {
Builtin::Srgb => "sRGB IEC61966-2.1",
Builtin::DisplayP3 => "Display P3",
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)",
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)",
Builtin::LinearSrgb => "Linear sRGB (Photocraft)",
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)",
Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)",
Builtin::SGray => "sGray (sRGB tone curve, Photocraft)",
Builtin::LabD50 => "Lab D50 identity (Photocraft)",
Builtin::CoatedCmyk => crate::synth::DESCRIPTION,
}
}
/// Looks up an id (case-insensitive; a few aliases and the descriptions are accepted).
pub fn from_id(s: &str) -> Option<Builtin> {
let n: String = s.to_ascii_lowercase().chars().filter(|c| c.is_ascii_alphanumeric()).collect();
let alias = match n.as_str() {
"srgb" | "srgbiec6196621" | "srgbiec61966" => Some(Builtin::Srgb),
"p3" | "displayp3" => Some(Builtin::DisplayP3),
"adobergb" | "adobergb1998" | "adobergbcompat" | "clayrgb" => Some(Builtin::AdobeRgbCompat),
"prophoto" | "prophotorgb" | "prophotocompat" | "romm" | "rommrgb" => Some(Builtin::ProPhotoCompat),
"linearsrgb" | "srgblinear" | "linear" => Some(Builtin::LinearSrgb),
"rec2020" | "bt2020" => Some(Builtin::Rec2020),
"gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22),
"sgray" | "graysrgb" => Some(Builtin::SGray),
"lab" | "labd50" | "cielab" => Some(Builtin::LabD50),
"cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk),
_ => None,
};
alias.or_else(|| Builtin::ALL.into_iter().find(|b| b.description().eq_ignore_ascii_case(s)))
}
/// The profile (built once per process).
pub fn profile(self) -> &'static Profile {
static CELLS: [OnceLock<Profile>; 10] = [const { OnceLock::new() }; 10];
// `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))
}
}
/// The default profile for a colour space: sRGB, sGray, the coated CMYK profile, Lab D50.
pub fn default_for(cs: ColorSpace) -> Option<&'static Profile> {
Some(match cs {
ColorSpace::Rgb => Builtin::Srgb.profile(),
ColorSpace::Gray => Builtin::SGray.profile(),
ColorSpace::Cmyk => Builtin::CoatedCmyk.profile(),
ColorSpace::Lab => Builtin::LabD50.profile(),
_ => return None,
})
}
pub fn srgb() -> &'static Profile {
Builtin::Srgb.profile()
}
/// Gray with a linear tone curve: what a grayscale OpenEXR holds. Not a [`Builtin`], as it is
/// only assigned to such files, never offered as a working space.
pub fn linear_gray() -> &'static Profile {
static PROFILE: OnceLock<Profile> = OnceLock::new();
PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0)))
}
/// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290).
const D65: [f64; 3] = math::xy_to_xyz(math::D65_XY);
/// A built-in RGB space: its RGB → XYZ matrix (relative to `white`) and white point.
#[derive(Clone, Copy)]
struct RgbSpace {
matrix: Mat3,
white: [f64; 3],
}
/// A built-in RGB space from primary chromaticities and a white point, evaluated in a constant
/// so that primaries that are not independent fail the build.
macro_rules! rgb_space {
($prim:expr, $white:expr) => {
match math::rgb_to_xyz_matrix($prim[0], $prim[1], $prim[2], $white) {
Some(matrix) => RgbSpace { matrix, white: $white },
None => panic!("built-in primaries are independent"),
}
};
}
const SRGB_SPACE: RgbSpace = rgb_space!([[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]], D65);
const P3_SPACE: RgbSpace = rgb_space!([[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]], D65);
const ADOBE_SPACE: RgbSpace = rgb_space!([[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]], D65);
const ROMM_SPACE: RgbSpace = rgb_space!([[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]], math::D50);
const BT2020_SPACE: RgbSpace = rgb_space!([[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]], D65);
/// Colorant matrix adapted to D50 and quantized to s15Fixed16, with each row nudged so that
/// RGB (1, 1, 1) maps exactly to the stored D50 white (keeps neutrals neutral).
fn colorants(space: RgbSpace) -> (Mat3, Option<Mat3>) {
let RgbSpace { matrix: m, white } = space;
let d50 = math::d50_quantized();
let adapt =
if (white[0] - math::D50[0]).abs() < 1e-3 && (white[2] - math::D50[2]).abs() < 1e-3 { None } else { Some(math::bradford(white, math::D50)) };
let m50 = match &adapt {
Some(a) => math::mul(a, &m),
None => {
// White already (nearly) D50: scale rows onto the exact PCS white.
let s = math::apply(&m, [1.0, 1.0, 1.0]);
let mut o = m;
for (i, row) in o.iter_mut().enumerate() {
for v in row.iter_mut() {
*v *= math::D50[i] / s[i];
}
}
o
}
};
let mut q = m50.map(|row| row.map(math::s15f16_round));
for (i, row) in q.iter_mut().enumerate() {
let err = d50[i] - row.iter().sum::<f64>();
let k = (0..3).max_by(|a, b| row[*a].abs().total_cmp(&row[*b].abs())).unwrap_or(0);
row[k] = math::s15f16_round(row[k] + err);
}
(q, adapt.map(|a| a.map(|row| row.map(math::s15f16_round))))
}
fn rgb(desc: &str, space: RgbSpace, trc: Curve) -> Profile {
let (m, chad) = colorants(space);
Profile {
version: (4, 0x30),
class: ProfileClass::Display,
color_space: ColorSpace::Rgb,
pcs: Pcs::Xyz,
rendering_intent: Intent::Perceptual,
description: desc.into(),
copyright: COPYRIGHT.into(),
white_point: math::d50_quantized(),
black_point: None,
chad,
matrix: Some(m),
trc: Some([trc.clone(), trc.clone(), trc]),
gray_trc: None,
a2b: [None, None, None],
b2a: [None, None, None],
bytes: None,
hash: Default::default(),
}
.with_encoded_bytes()
}
fn gray(desc: &str, trc: Curve) -> Profile {
Profile {
version: (4, 0x30),
class: ProfileClass::Display,
color_space: ColorSpace::Gray,
pcs: Pcs::Xyz,
rendering_intent: Intent::Perceptual,
description: desc.into(),
copyright: COPYRIGHT.into(),
white_point: math::d50_quantized(),
black_point: None,
chad: None,
matrix: None,
trc: None,
gray_trc: Some(trc),
a2b: [None, None, None],
b2a: [None, None, None],
bytes: None,
hash: Default::default(),
}
.with_encoded_bytes()
}
fn lab() -> Profile {
let ident = || Lut { kind: LutKind::Ab, inputs: 3, outputs: 3, stages: vec![Stage::Curves(vec![Curve::Identity; 3])] };
Profile {
version: (4, 0x30),
class: ProfileClass::ColorSpace,
color_space: ColorSpace::Lab,
pcs: Pcs::Lab,
rendering_intent: Intent::Perceptual,
description: Builtin::LabD50.description().into(),
copyright: COPYRIGHT.into(),
white_point: math::d50_quantized(),
black_point: None,
chad: None,
matrix: None,
trc: None,
gray_trc: None,
a2b: [Some(ident()), None, None],
b2a: [Some(ident()), None, None],
bytes: None,
hash: Default::default(),
}
.with_encoded_bytes()
}
/// 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");
fn build(b: Builtin) -> Profile {
match b {
Builtin::Srgb => rgb(b.description(), SRGB_SPACE, srgb_trc()),
Builtin::DisplayP3 => rgb(b.description(), P3_SPACE, srgb_trc()),
Builtin::AdobeRgbCompat => rgb(b.description(), ADOBE_SPACE, Curve::Gamma(563.0 / 256.0)),
Builtin::ProPhotoCompat => rgb(b.description(), ROMM_SPACE, Curve::Gamma(1.8)),
Builtin::LinearSrgb => rgb(b.description(), SRGB_SPACE, Curve::Gamma(1.0)),
Builtin::Rec2020 => {
// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
let a = 1.099;
let trc = Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] };
rgb(b.description(), BT2020_SPACE, trc)
}
Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)),
Builtin::SGray => gray(b.description(), srgb_trc()),
Builtin::LabD50 => lab(),
// 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()),
}
}
+248
View File
@@ -0,0 +1,248 @@
//! Multi-dimensional colour lookup tables (ICC CLUTs) with tetrahedral interpolation.
//!
//! Grid layout follows ICC.1: the first input channel varies slowest, output values of one
//! node are stored together. 3-input tables use tetrahedral interpolation (Sakamoto 1980 /
//! Kasson et al. 1995); 4-input tables interpolate linearly along the first input between
//! two tetrahedral evaluations; other input counts use multilinear interpolation.
/// Maximum number of input or output channels of a CLUT.
pub const MAX_CHANNELS: usize = 15;
#[derive(Clone, Debug, PartialEq)]
pub struct Clut {
pub inputs: usize,
pub outputs: usize,
/// Grid points per input dimension (each ≥ 2, or 1 for a degenerate dimension).
pub grid: Vec<usize>,
/// Normalised node values, `outputs` per node.
pub data: Vec<f32>,
strides: Vec<usize>,
}
impl Clut {
/// Builds a table from node data. Panics if `data` has the wrong length.
pub fn new(grid: Vec<usize>, outputs: usize, data: Vec<f32>) -> Self {
let inputs = grid.len();
assert!((1..=MAX_CHANNELS).contains(&inputs) && (1..=MAX_CHANNELS).contains(&outputs), "bad CLUT dimensions");
let mut strides = vec![0usize; inputs];
let mut s = outputs;
for i in (0..inputs).rev() {
strides[i] = s;
s *= grid[i].max(1);
}
assert_eq!(data.len(), s, "CLUT data length");
Clut { inputs, outputs, grid, data, strides }
}
/// Number of nodes.
pub fn nodes(&self) -> usize {
self.data.len() / self.outputs
}
/// Builds a table by sampling `f` at every node (inputs normalised to `[0, 1]`).
pub fn sample(grid: Vec<usize>, outputs: usize, mut f: impl FnMut(&[f32], &mut [f32])) -> Self {
let inputs = grid.len();
let total: usize = grid.iter().product();
let mut data = vec![0.0f32; total * outputs];
let mut idx = vec![0usize; inputs];
let mut inp = vec![0.0f32; inputs];
for node in 0..total {
let mut rem = node;
for d in (0..inputs).rev() {
idx[d] = rem % grid[d];
rem /= grid[d];
}
for d in 0..inputs {
inp[d] = if grid[d] > 1 { idx[d] as f32 / (grid[d] - 1) as f32 } else { 0.0 };
}
f(&inp, &mut data[node * outputs..(node + 1) * outputs]);
}
Clut::new(grid, outputs, data)
}
#[inline]
fn locate(&self, d: usize, v: f32) -> (usize, f32, usize) {
let n = self.grid[d];
if n < 2 {
return (0, 0.0, 0);
}
let p = v.clamp(0.0, 1.0) * (n - 1) as f32;
let i = (p as usize).min(n - 2);
(i * self.strides[d], p - i as f32, self.strides[d])
}
/// Interpolates at `input` (`inputs` values) into `out` (`outputs` values).
#[inline]
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
match self.inputs {
1 => self.eval1(input[0], out),
3 => {
let (b0, r0, s0) = self.locate(0, input[0]);
let (b1, r1, s1) = self.locate(1, input[1]);
let (b2, r2, s2) = self.locate(2, input[2]);
self.tetra(b0 + b1 + b2, [s0, s1, s2], [r0, r1, r2], out);
}
4 => {
let (b0, r0, s0) = self.locate(0, input[0]);
let (b1, r1, s1) = self.locate(1, input[1]);
let (b2, r2, s2) = self.locate(2, input[2]);
let (b3, r3, s3) = self.locate(3, input[3]);
let base = b0 + b1 + b2 + b3;
let mut lo = [0.0f32; MAX_CHANNELS];
self.tetra(base, [s1, s2, s3], [r1, r2, r3], &mut lo[..self.outputs]);
if r0 > 0.0 && s0 > 0 {
let mut hi = [0.0f32; MAX_CHANNELS];
self.tetra(base + s0, [s1, s2, s3], [r1, r2, r3], &mut hi[..self.outputs]);
for (o, (l, h)) in out.iter_mut().zip(lo.iter().zip(&hi)) {
*o = l + (h - l) * r0;
}
} else {
out[..self.outputs].copy_from_slice(&lo[..self.outputs]);
}
}
_ => self.eval_multilinear(input, out),
}
}
fn eval1(&self, v: f32, out: &mut [f32]) {
let (b, r, s) = self.locate(0, v);
for (k, o) in out.iter_mut().enumerate().take(self.outputs) {
let a = self.data[b + k];
let c = if s > 0 { self.data[b + s + k] } else { a };
*o = a + (c - a) * r;
}
}
/// Tetrahedral interpolation inside one cube of three dimensions.
#[inline]
fn tetra(&self, base: usize, s: [usize; 3], r: [f32; 3], out: &mut [f32]) {
let d = &self.data;
let [rx, ry, rz] = r;
let [sx, sy, sz] = s;
let c000 = base;
let c100 = base + sx;
let c010 = base + sy;
let c001 = base + sz;
let c110 = base + sx + sy;
let c101 = base + sx + sz;
let c011 = base + sy + sz;
let c111 = base + sx + sy + sz;
// Pick the tetrahedron containing (rx, ry, rz) and the path through the cube corners.
let (p1, p2, p3, w1, w2, w3) = if rx >= ry {
if ry >= rz {
(c100, c110, c111, rx, ry, rz)
} else if rx >= rz {
(c100, c101, c111, rx, rz, ry)
} else {
(c001, c101, c111, rz, rx, ry)
}
} else if rx >= rz {
(c010, c110, c111, ry, rx, rz)
} else if ry >= rz {
(c010, c011, c111, ry, rz, rx)
} else {
(c001, c011, c111, rz, ry, rx)
};
for (k, o) in out.iter_mut().enumerate().take(self.outputs) {
let v0 = d[c000 + k];
let v1 = d[p1 + k];
let v2 = d[p2 + k];
let v3 = d[p3 + k];
*o = v0 + (v1 - v0) * w1 + (v2 - v1) * w2 + (v3 - v2) * w3;
}
}
/// Trilinear interpolation (3 inputs), kept as a reference for accuracy comparisons.
pub fn eval_trilinear(&self, input: &[f32], out: &mut [f32]) {
assert_eq!(self.inputs, 3);
self.eval_multilinear(input, out);
}
/// Multilinear interpolation over all 2^n cube corners.
pub fn eval_multilinear(&self, input: &[f32], out: &mut [f32]) {
let n = self.inputs;
let mut base = 0usize;
let mut r = [0.0f32; MAX_CHANNELS];
let mut s = [0usize; MAX_CHANNELS];
for d in 0..n {
let (b, rr, ss) = self.locate(d, input[d]);
base += b;
r[d] = rr;
s[d] = ss;
}
let o = self.outputs;
out[..o].iter_mut().for_each(|v| *v = 0.0);
for corner in 0..(1usize << n) {
let mut w = 1.0f32;
let mut off = base;
for d in 0..n {
if corner & (1 << d) != 0 {
w *= r[d];
off += s[d];
} else {
w *= 1.0 - r[d];
}
}
if w == 0.0 {
continue;
}
for (k, v) in out[..o].iter_mut().enumerate() {
*v += w * self.data[off + k];
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn f3(p: &[f32], o: &mut [f32]) {
o[0] = p[0] * 0.5 + p[1] * 0.3 + p[2] * 0.2;
o[1] = p[0] * p[1];
o[2] = (p[2] * 3.0).sin() * 0.5 + 0.5;
}
#[test]
fn exact_on_nodes_and_linear_functions() {
let c = Clut::sample(vec![5, 5, 5], 3, f3);
let mut o = [0.0; 3];
c.eval(&[0.25, 0.5, 0.75], &mut o);
let mut w = [0.0; 3];
f3(&[0.25, 0.5, 0.75], &mut w);
for i in 0..3 {
assert!((o[i] - w[i]).abs() < 1e-6);
}
// Linear function is reproduced exactly anywhere.
c.eval(&[0.13, 0.77, 0.41], &mut o);
assert!((o[0] - (0.13 * 0.5 + 0.77 * 0.3 + 0.41 * 0.2)).abs() < 1e-6);
}
#[test]
fn four_d_and_multilinear_agree_on_linear() {
let f = |p: &[f32], o: &mut [f32]| o[0] = 0.1 * p[0] + 0.2 * p[1] + 0.3 * p[2] + 0.4 * p[3];
let c = Clut::sample(vec![3, 4, 5, 6], 1, f);
let mut a = [0.0];
let mut b = [0.0];
for p in [[0.1f32, 0.9, 0.3, 0.7], [1.0, 0.0, 0.5, 0.5], [0.33, 0.66, 0.99, 0.01]] {
c.eval(&p, &mut a);
c.eval_multilinear(&p, &mut b);
let want = 0.1 * p[0] + 0.2 * p[1] + 0.3 * p[2] + 0.4 * p[3];
assert!((a[0] - want).abs() < 1e-5 && (b[0] - want).abs() < 1e-5);
}
}
#[test]
fn one_and_two_d() {
let c = Clut::sample(vec![3], 2, |p, o| {
o[0] = p[0];
o[1] = 1.0 - p[0];
});
let mut o = [0.0; 2];
c.eval(&[0.25], &mut o);
assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.75).abs() < 1e-6);
let c = Clut::sample(vec![2, 2], 1, |p, o| o[0] = p[0] + p[1]);
c.eval(&[0.5, 0.25], &mut o);
assert!((o[0] - 0.75).abs() < 1e-6);
}
}
+339
View File
@@ -0,0 +1,339 @@
//! One-dimensional tone curves (ICC `curv` and `para` tag types) and their inverses.
/// A tone reproduction curve.
///
/// Gamma and parametric curves are evaluated analytically, including outside `[0, 1]` (negative
/// inputs mirror, values above 1 extend the formula), so unbounded float transforms keep HDR
/// values. Sampled tables clamp their input to `[0, 1]`.
#[derive(Clone, Debug, PartialEq)]
pub enum Curve {
Identity,
/// `y = x^g`.
Gamma(f64),
/// ICC `parametricCurveType` function `kind` (0–4) with parameters `[g, a, b, c, d, e, f]`
/// (unused ones are 0).
Parametric {
kind: u16,
p: [f64; 7],
},
/// Samples at evenly spaced inputs over `[0, 1]`, normalised outputs.
Table(Vec<f32>),
}
/// The sRGB transfer function (IEC 61966-2.1) as ICC parametric type 3.
pub fn srgb_trc() -> Curve {
Curve::Parametric { kind: 3, p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0] }
}
impl Curve {
/// Number of parameters stored for a parametric function type.
pub fn param_count(kind: u16) -> usize {
match kind {
0 => 1,
1 => 3,
2 => 4,
3 => 5,
_ => 7,
}
}
pub fn is_identity(&self) -> bool {
match self {
Curve::Identity => true,
Curve::Gamma(g) => (*g - 1.0).abs() < 1e-9,
Curve::Parametric { kind: 0, p } => (p[0] - 1.0).abs() < 1e-9,
Curve::Table(t) => {
let n = t.len();
n < 2 || t.iter().enumerate().all(|(i, v)| (*v as f64 - i as f64 / (n - 1) as f64).abs() < 1.0 / 65535.0)
}
_ => false,
}
}
/// Evaluates the curve at `x` (f64 reference path).
pub fn eval64(&self, x: f64) -> f64 {
match self {
Curve::Identity => x,
Curve::Gamma(g) => mirror(x, |v| v.powf(*g)),
Curve::Parametric { kind, p } => para(*kind, p, x),
Curve::Table(t) => table(t, x as f32) as f64,
}
}
#[inline]
pub fn eval(&self, x: f32) -> f32 {
match self {
Curve::Identity => x,
Curve::Table(t) => table(t, x),
_ => self.eval64(x as f64) as f32,
}
}
/// Evaluates the inverse curve at `y`.
pub fn eval_inverse64(&self, y: f64) -> f64 {
match self {
Curve::Identity => y,
Curve::Gamma(g) => {
if g.abs() < 1e-12 {
y
} else {
mirror(y, |v| v.powf(1.0 / g))
}
}
Curve::Parametric { kind, p } => para_inverse(*kind, p, y).unwrap_or_else(|| numeric_inverse(|x| para(*kind, p, x), y)),
Curve::Table(t) => table_inverse(t, y as f32) as f64,
}
}
#[inline]
pub fn eval_inverse(&self, y: f32) -> f32 {
match self {
Curve::Identity => y,
Curve::Table(t) => table_inverse(t, y),
_ => self.eval_inverse64(y as f64) as f32,
}
}
/// Samples `n` evenly spaced points over `[0, 1]`.
pub fn sample(&self, n: usize) -> Vec<f32> {
(0..n).map(|i| self.eval64(i as f64 / (n - 1) as f64) as f32).collect()
}
/// `true` when the curve never decreases over `[0, 1]`.
pub fn is_monotonic(&self) -> bool {
let s = self.sample(1024);
s.windows(2).all(|w| w[1] >= w[0] - 1e-6) || s.windows(2).all(|w| w[1] <= w[0] + 1e-6)
}
}
#[inline]
fn mirror(x: f64, f: impl Fn(f64) -> f64) -> f64 {
if x < 0.0 { -f(-x) } else { f(x) }
}
fn pw(base: f64, g: f64) -> f64 {
if base <= 0.0 { 0.0 } else { base.powf(g) }
}
fn para(kind: u16, p: &[f64; 7], x: f64) -> f64 {
let [g, a, b, c, d, e, f] = *p;
match kind {
0 => mirror(x, |v| v.powf(g)),
1 => {
if a != 0.0 && x >= -b / a {
pw(a * x + b, g)
} else {
0.0
}
}
2 => {
if a != 0.0 && x >= -b / a {
pw(a * x + b, g) + c
} else {
c
}
}
3 => {
if x < 0.0 && b == 0.0 && d == 0.0 {
// Pure gamma written as type 3: mirror negatives.
-para(kind, p, -x)
} else if x >= d {
pw(a * x + b, g)
} else {
c * x
}
}
_ => {
if x >= d {
pw(a * x + b, g) + e
} else {
c * x + f
}
}
}
}
fn para_inverse(kind: u16, p: &[f64; 7], y: f64) -> Option<f64> {
let [g, a, b, c, d, e, f] = *p;
if g.abs() < 1e-12 {
return None;
}
let root = |v: f64| if v <= 0.0 { 0.0 } else { v.powf(1.0 / g) };
Some(match kind {
0 => mirror(y, |v| v.powf(1.0 / g)),
1 => {
if a == 0.0 {
return None;
}
if y <= 0.0 { -b / a } else { (root(y) - b) / a }
}
2 => {
if a == 0.0 {
return None;
}
if y <= c { -b / a } else { (root(y - c) - b) / a }
}
3 => {
if a == 0.0 {
return None;
}
if y < 0.0 && b == 0.0 && d == 0.0 {
return para_inverse(kind, p, -y).map(|v| -v);
}
let yd = pw(a * d + b, g);
if y >= yd {
(root(y) - b) / a
} else if c != 0.0 {
y / c
} else {
0.0
}
}
_ => {
if a == 0.0 {
return None;
}
let yd = pw(a * d + b, g) + e;
if y >= yd {
(root(y - e) - b) / a
} else if c != 0.0 {
(y - f) / c
} else {
0.0
}
}
})
}
/// Bisection inverse of an increasing function on `[0, 1]`.
fn numeric_inverse(f: impl Fn(f64) -> f64, y: f64) -> f64 {
let (f0, f1) = (f(0.0), f(1.0));
let increasing = f1 >= f0;
let (mut lo, mut hi) = (0.0f64, 1.0f64);
for _ in 0..60 {
let mid = 0.5 * (lo + hi);
let v = f(mid);
if (v < y) == increasing {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
#[inline]
fn table(t: &[f32], x: f32) -> f32 {
let n = t.len();
match n {
0 => x,
1 => t[0],
_ => {
let pos = x.clamp(0.0, 1.0) * (n - 1) as f32;
let i = (pos as usize).min(n - 2);
let f = pos - i as f32;
t[i] + (t[i + 1] - t[i]) * f
}
}
}
/// Exact inverse of a piecewise-linear table (binary search), for increasing or decreasing tables.
fn table_inverse(t: &[f32], y: f32) -> f32 {
let n = t.len();
if n < 2 {
return y;
}
let increasing = t[n - 1] >= t[0];
let (lo_v, hi_v) = if increasing { (t[0], t[n - 1]) } else { (t[n - 1], t[0]) };
if y <= lo_v {
// Flat start: choose the last index still at the lowest value.
return if increasing { edge_index(t, true) } else { edge_index(t, false) };
}
if y >= hi_v {
return if increasing { edge_index(t, false) } else { edge_index(t, true) };
}
let (mut lo, mut hi) = (0usize, n - 1);
while hi - lo > 1 {
let mid = (lo + hi) / 2;
if (t[mid] < y) == increasing {
lo = mid;
} else {
hi = mid;
}
}
let (a, b) = (t[lo], t[hi]);
let f = if (b - a).abs() > 1e-12 { (y - a) / (b - a) } else { 0.5 };
(lo as f32 + f) / (n - 1) as f32
}
/// For a flat run at the table start (`start = true`) the last index of the run, or for a flat
/// run at the end the first index of it, as a normalised input.
fn edge_index(t: &[f32], start: bool) -> f32 {
let n = t.len();
if start {
let v = t[0];
let i = t.iter().position(|x| (*x - v).abs() > 1e-7).map_or(n - 1, |i| i - 1);
i as f32 / (n - 1) as f32
} else {
let v = t[n - 1];
let i = t.iter().rposition(|x| (*x - v).abs() > 1e-7).map_or(0, |i| i + 1);
i as f32 / (n - 1) as f32
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn srgb_matches_formula() {
let c = srgb_trc();
for i in 0..=255 {
let v = i as f64 / 255.0;
let want = if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
assert!((c.eval64(v) - want).abs() < 1e-12);
assert!((c.eval_inverse64(want) - v).abs() < 1e-9);
}
}
#[test]
fn parametric_inverses() {
let curves = [
Curve::Parametric { kind: 0, p: [2.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] },
Curve::Parametric { kind: 1, p: [2.0, 0.9, 0.1, 0.0, 0.0, 0.0, 0.0] },
Curve::Parametric { kind: 2, p: [2.0, 0.9, 0.1, 0.05, 0.0, 0.0, 0.0] },
srgb_trc(),
Curve::Parametric { kind: 4, p: [2.4, 0.9, 0.05, 0.08, 0.04, 0.01, 0.002] },
Curve::Gamma(1.8),
];
for c in &curves {
for i in 1..100 {
let x = i as f64 / 100.0;
let y = c.eval64(x);
assert!((c.eval_inverse64(y) - x).abs() < 1e-6, "{c:?} at {x}");
}
}
}
#[test]
fn table_inverse_exact_on_knots_and_between() {
let t = Curve::Gamma(2.2).sample(1024);
let c = Curve::Table(t);
for i in 0..=100 {
let x = i as f32 / 100.0;
let back = c.eval_inverse(c.eval(x));
assert!((back - x).abs() < 1e-4, "{x} -> {back}");
}
let dec = Curve::Table(vec![1.0, 0.5, 0.0]);
assert!((dec.eval_inverse(0.25) - 0.75).abs() < 1e-6);
}
#[test]
fn hdr_and_negative_extension() {
let c = srgb_trc();
assert!(c.eval64(2.0) > 1.0);
assert!((c.eval_inverse64(c.eval64(2.0)) - 2.0).abs() < 1e-9);
let g = Curve::Gamma(2.2);
assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12);
}
}
+78
View File
@@ -0,0 +1,78 @@
//! Gamut checks against a proof (output) profile: Photoshop's View › Gamut Warning.
use crate::clut::Clut;
use crate::pipeline::{Pipeline, Stage};
use crate::profile::Profile;
use crate::transform::link_stages;
use crate::{CmsError, Intent};
/// Default ΔE76 threshold above which a colour counts as out of gamut.
pub const DEFAULT_THRESHOLD: f32 = 4.0;
/// Precomputed out-of-gamut test for colours of `src` against `proof`.
///
/// A colour is out of gamut when sending it to the proof device (relative colorimetric,
/// clipping) and back to Lab moves it by more than the threshold (ΔE76). The ΔE is tabulated
/// over the source device space (33³ grid for 3 channels, 17⁴ for 4, exact otherwise).
#[derive(Debug)]
pub struct GamutCheck {
inputs: usize,
grid: Option<Clut>,
exact: (Pipeline, Pipeline),
pub threshold: f32,
}
impl GamutCheck {
pub fn new(src: &Profile, proof: &Profile, threshold: f32) -> Result<GamutCheck, CmsError> {
let lab = crate::Builtin::LabD50.profile();
let direct = Pipeline::new(src.channels(), link_stages(src, lab, Intent::RelativeColorimetric, false)?);
let mut via = link_stages(src, proof, Intent::RelativeColorimetric, false)?;
via.push(Stage::Clamp01);
via.extend(link_stages(proof, lab, Intent::RelativeColorimetric, false)?);
let via = Pipeline::new(src.channels(), via);
let de = |p: &[f32], o: &mut [f32]| {
let (mut a, mut b) = ([0.0f32; 16], [0.0f32; 16]);
direct.eval(p, &mut a);
via.eval(p, &mut b);
let d = |i: usize, s: f32| (a[i] - b[i]) * s;
o[0] = (d(0, 100.0).powi(2) + d(1, 255.0).powi(2) + d(2, 255.0).powi(2)).sqrt();
};
let n = src.channels();
let grid = match n {
1 => Some(Clut::sample(vec![256], 1, de)),
3 => Some(Clut::sample(vec![33; 3], 1, de)),
4 => Some(Clut::sample(vec![17; 4], 1, de)),
_ => None,
};
Ok(GamutCheck { inputs: n, grid, exact: (direct, via), threshold })
}
pub fn inputs(&self) -> usize {
self.inputs
}
/// ΔE76 between the colour and its proof round trip.
pub fn delta_e(&self, px: &[f32]) -> f32 {
let mut o = [0.0f32; 1];
match &self.grid {
Some(g) => {
let mut c = [0.0f32; 16];
for (d, s) in c.iter_mut().zip(&px[..self.inputs]) {
*d = s.clamp(0.0, 1.0);
}
g.eval(&c[..self.inputs], &mut o);
}
None => {
let (mut a, mut b) = ([0.0f32; 16], [0.0f32; 16]);
self.exact.0.eval(px, &mut a);
self.exact.1.eval(px, &mut b);
o[0] = ((a[0] - b[0]) * 100.0).hypot((a[1] - b[1]) * 255.0).hypot((a[2] - b[2]) * 255.0);
}
}
o[0]
}
pub fn out_of_gamut(&self, px: &[f32]) -> bool {
self.delta_e(px) > self.threshold
}
}
+106
View File
@@ -0,0 +1,106 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Colour management for the Craft suite: a pure-Rust ICC v2/v4 colour management module.
//!
//! * [`Profile`] parses ICC profiles (matrix/TRC RGB, gray TRC, `mft1`/`mft2`/`mAB `/`mBA `
//! LUT profiles incl. CMYK↔Lab, `curv`/`para` curves) and writes ICC v4 profiles.
//! * [`Transform`] connects two profiles through the PCS (XYZ/Lab D50) with a rendering
//! [`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.
//! * [`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,
//! CIE 15, and Adobe's published black point compensation paper.
#![forbid(unsafe_code)]
pub mod builtin;
pub mod clut;
pub mod curve;
pub mod gamut;
pub mod lut3d;
pub mod lutfile;
pub mod math;
pub mod pipeline;
pub mod profile;
pub mod synth;
pub mod transform;
mod write;
pub use builtin::Builtin;
pub use clut::Clut;
pub use curve::Curve;
pub use gamut::GamutCheck;
pub use lut3d::Lut3d;
pub use profile::{ColorSpace, Pcs, Profile, ProfileClass};
pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached};
/// ICC rendering intent.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub enum Intent {
Perceptual = 0,
#[default]
RelativeColorimetric = 1,
Saturation = 2,
AbsoluteColorimetric = 3,
}
impl Intent {
pub const ALL: [Intent; 4] = [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation, Intent::AbsoluteColorimetric];
pub fn from_u32(v: u32) -> Option<Intent> {
Some(match v {
0 => Intent::Perceptual,
1 => Intent::RelativeColorimetric,
2 => Intent::Saturation,
3 => Intent::AbsoluteColorimetric,
_ => return None,
})
}
/// Parses `perceptual`, `relative`/`relativeColorimetric`, `saturation`,
/// `absolute`/`absoluteColorimetric` (case-insensitive).
pub fn parse(s: &str) -> Option<Intent> {
let n: String = s.to_ascii_lowercase().chars().filter(|c| c.is_ascii_alphanumeric()).collect();
Some(match n.as_str() {
"perceptual" | "0" => Intent::Perceptual,
"relative" | "relativecolorimetric" | "colorimetric" | "1" => Intent::RelativeColorimetric,
"saturation" | "2" => Intent::Saturation,
"absolute" | "absolutecolorimetric" | "3" => Intent::AbsoluteColorimetric,
_ => return None,
})
}
pub fn id(self) -> &'static str {
match self {
Intent::Perceptual => "perceptual",
Intent::RelativeColorimetric => "relative",
Intent::Saturation => "saturation",
Intent::AbsoluteColorimetric => "absolute",
}
}
}
/// Errors from parsing profiles or building transforms.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CmsError {
Truncated,
/// Missing `acsp` signature: not an ICC profile.
BadSignature,
Invalid(String),
Unsupported(String),
}
impl std::fmt::Display for CmsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CmsError::Truncated => write!(f, "ICC profile is truncated"),
CmsError::BadSignature => write!(f, "not an ICC profile (missing 'acsp' signature)"),
CmsError::Invalid(m) => write!(f, "invalid ICC profile: {m}"),
CmsError::Unsupported(m) => write!(f, "unsupported ICC feature: {m}"),
}
}
}
impl std::error::Error for CmsError {}
+143
View File
@@ -0,0 +1,143 @@
//! 3D lookup tables for applying a display transform on the GPU.
use crate::Transform;
/// An `size³` RGBA lookup table: entry `r + g·size + b·size²` holds the output for input
/// `(r, g, b) / (size − 1)`, alpha 1. This is the memory order of a 3D texture whose x axis is
/// red, so it uploads directly (sample at `(c·(size−1) + 0.5) / size` with linear filtering).
#[derive(Clone, Debug, PartialEq)]
pub struct Lut3d {
pub size: usize,
pub data: Vec<[f32; 4]>,
}
impl Lut3d {
/// Samples a 3-input transform (1-output transforms are replicated to gray).
pub fn from_transform(t: &Transform, size: usize) -> Lut3d {
assert!(t.inputs() == 3 && (t.outputs() == 3 || t.outputs() == 1), "display LUTs need a 3→3 or 3→1 transform");
assert!(size >= 2, "LUT size");
let mut data = vec![[0.0f32, 0.0, 0.0, 1.0]; size * size * size];
let s = (size - 1) as f32;
let mut out = [0.0f32; 16];
for b in 0..size {
for g in 0..size {
for r in 0..size {
t.eval(&[r as f32 / s, g as f32 / s, b as f32 / s], &mut out);
let v = if t.outputs() == 1 { [out[0]; 3] } else { [out[0], out[1], out[2]] };
data[r + g * size + b * size * size] = [v[0], v[1], v[2], 1.0];
}
}
}
Lut3d { size, data }
}
/// Trilinear lookup (what GPU filtering computes).
pub fn sample(&self, rgb: [f32; 3]) -> [f32; 3] {
let n = self.size;
let loc = |v: f32| {
let p = v.clamp(0.0, 1.0) * (n - 1) as f32;
let i = (p as usize).min(n - 2);
(i, p - i as f32)
};
let (r0, fr) = loc(rgb[0]);
let (g0, fg) = loc(rgb[1]);
let (b0, fb) = loc(rgb[2]);
let at = |r: usize, g: usize, b: usize| self.data[r + g * n + b * n * n];
let mut o = [0.0f32; 3];
for (k, ov) in o.iter_mut().enumerate() {
let c = |r, g, b| at(r, g, b)[k];
let x00 = c(r0, g0, b0) + (c(r0 + 1, g0, b0) - c(r0, g0, b0)) * fr;
let x10 = c(r0, g0 + 1, b0) + (c(r0 + 1, g0 + 1, b0) - c(r0, g0 + 1, b0)) * fr;
let x01 = c(r0, g0, b0 + 1) + (c(r0 + 1, g0, b0 + 1) - c(r0, g0, b0 + 1)) * fr;
let x11 = c(r0, g0 + 1, b0 + 1) + (c(r0 + 1, g0 + 1, b0 + 1) - c(r0, g0 + 1, b0 + 1)) * fr;
let y0 = x00 + (x10 - x00) * fg;
let y1 = x01 + (x11 - x01) * fg;
*ov = y0 + (y1 - y0) * fb;
}
o
}
/// Little-endian RGBA16F texels (8 bytes each), e.g. for `wgpu::TextureFormat::Rgba16Float`.
pub fn to_rgba16f_bytes(&self) -> Vec<u8> {
let mut o = Vec::with_capacity(self.data.len() * 8);
for px in &self.data {
for v in px {
o.extend_from_slice(&f32_to_f16(*v).to_le_bytes());
}
}
o
}
/// RGBA8 texels (for backends without float 3D textures).
pub fn to_rgba8(&self) -> Vec<u8> {
self.data.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)).collect()
}
}
/// IEEE 754 binary16 bits of `v` (round to nearest even; overflow → ±inf, NaN kept).
pub fn f32_to_f16(v: f32) -> u16 {
let x = v.to_bits();
let sign = ((x >> 16) & 0x8000) as u16;
let exp = ((x >> 23) & 0xFF) as i32;
let man = x & 0x7F_FFFF;
if exp == 0xFF {
return sign | 0x7C00 | if man != 0 { 0x200 } else { 0 };
}
let e = exp - 127 + 15;
if e >= 0x1F {
return sign | 0x7C00;
}
if e <= 0 {
if e < -10 {
return sign;
}
let m = man | 0x80_0000;
let shift = (14 - e) as u32;
let half = 1u32 << (shift - 1);
let rem = m & ((1 << shift) - 1);
let mut r = m >> shift;
if rem > half || (rem == half && r & 1 == 1) {
r += 1;
}
return sign | r as u16;
}
let mut r = ((e as u32) << 10) | (man >> 13);
let rem = man & 0x1FFF;
if rem > 0x1000 || (rem == 0x1000 && r & 1 == 1) {
r += 1;
}
sign | r as u16
}
/// binary16 bits → f32.
pub fn f16_to_f32(h: u16) -> f32 {
let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
let e = ((h >> 10) & 0x1F) as i32;
let m = (h & 0x3FF) as f32;
match e {
0 => sign * m * 2f32.powi(-24),
0x1F => {
if m == 0.0 {
sign * f32::INFINITY
} else {
f32::NAN
}
}
_ => sign * (1.0 + m / 1024.0) * 2f32.powi(e - 15),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn half_float_roundtrip() {
for v in [0.0f32, 1.0, 0.5, 0.333, 1e-5, 65504.0, -2.25, 0.1] {
let back = f16_to_f32(f32_to_f16(v));
assert!((back - v).abs() <= v.abs() * 1e-3 + 1e-7, "{v} -> {back}");
}
assert_eq!(f32_to_f16(1.0), 0x3C00);
assert_eq!(f32_to_f16(1e6), 0x7C00);
}
}
+446
View File
@@ -0,0 +1,446 @@
//! Creative 3D LUT files for Image › Adjustments › Color Lookup: `.cube` (Adobe/Resolve), `.3dl`
//! (Autodesk Lustre / Flame) and `.look` (SpeedGrade XML), plus a few generated (CC0) looks.
//!
//! Every table is normalised to [`LutFile`]: `size`³ RGB triplets in 0..=1, **red varying
//! fastest** (`((b·size + g)·size + r)·3`). Formats follow their public descriptions: the Adobe
//! "Cube LUT Specification 1.0", the Lustre 3DL layout as documented by OpenColorIO, and the
//! SpeedGrade `.look` XML (hex-encoded little-endian float32 RGB triplets).
/// A parsed 3D LUT.
#[derive(Clone, Debug, PartialEq)]
pub struct LutFile {
pub title: String,
pub size: usize,
/// `size`³ × 3 values, red fastest.
pub data: Vec<f32>,
/// The input domain the table expects (`.cube` `DOMAIN_MIN`/`DOMAIN_MAX`,
/// `LUT_3D_INPUT_RANGE`): an input `c` samples the table at `(c - min) / (max - min)`,
/// saturating outside. `[0, 1]` is the identity default; applying a table with any other
/// domain is rejected upstream until Color Lookup carries it through.
pub domain_min: [f32; 3],
pub domain_max: [f32; 3],
}
/// Largest edge length accepted (64³ is Photoshop's practical maximum; 129 leaves headroom while
/// keeping the GPU table within texture limits).
pub const MAX_SIZE: usize = 129;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LutError(pub String);
impl std::fmt::Display for LutError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for LutError {}
fn err<T>(msg: impl Into<String>) -> Result<T, LutError> {
Err(LutError(msg.into()))
}
fn checked_data_len(size: usize) -> Result<(usize, usize), LutError> {
if !(2..=MAX_SIZE).contains(&size) {
return err(format!("unsupported LUT size {size}"));
}
let entries = size.checked_pow(3).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
let data_len = entries.checked_mul(3).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
Ok((entries, data_len))
}
impl LutFile {
/// The identity table of edge `size`.
pub fn identity(size: usize) -> Self {
Self::from_fn("Identity", size, |c| c)
}
/// Samples `f` on an `size`³ grid.
pub fn from_fn(title: &str, size: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> Self {
let m = (size.max(2) - 1) as f32;
let mut data = Vec::with_capacity(size * size * size * 3);
for b in 0..size {
for g in 0..size {
for r in 0..size {
let o = f([r as f32 / m, g as f32 / m, b as f32 / m]);
data.extend(o.map(|v| v.clamp(0.0, 1.0)));
}
}
}
LutFile { title: title.to_string(), size, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }
}
/// Whether the table expects plain 0..1 inputs (the common case; Photoshop's default).
pub fn domain_is_default(&self) -> bool {
self.domain_min == [0.0, 0.0, 0.0] && self.domain_max == [1.0, 1.0, 1.0]
}
fn check(self) -> Result<Self, LutError> {
let (entries, data_len) = checked_data_len(self.size)?;
if self.data.len() != data_len {
return err(format!("expected {entries} entries, found {}", self.data.len() / 3));
}
if self.data.iter().any(|v| !v.is_finite()) {
return err("non-finite LUT value");
}
Ok(self)
}
}
/// Parses a LUT, picking the format from `file_name`'s extension (falls back to sniffing).
pub fn parse(file_name: &str, bytes: &[u8]) -> Result<LutFile, LutError> {
let lower = file_name.to_ascii_lowercase();
let text = String::from_utf8_lossy(bytes);
if lower.ends_with(".3dl") {
parse_3dl(&text)
} else if lower.ends_with(".look") || text.trim_start().starts_with('<') {
parse_look(&text)
} else {
parse_cube(&text)
}
}
/// Adobe / Resolve `.cube`. 1D LUTs (`LUT_1D_SIZE`) are expanded to a 33³ table.
pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
let mut title = String::new();
let (mut size3, mut size1) = (0usize, 0usize);
let (mut dmin, mut dmax) = ([0.0f32; 3], [1.0f32; 3]);
let mut rows: Vec<f32> = Vec::new();
for line in text.lines() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let mut it = line.split_whitespace();
let key = it.next().unwrap_or("");
let nums = |it: std::str::SplitWhitespace<'_>| -> Result<[f32; 3], LutError> {
let v: Vec<f32> = it.filter_map(|s| s.parse().ok()).collect();
if v.len() == 3 { Ok([v[0], v[1], v[2]]) } else { err(format!("bad line `{line}`")) }
};
match key {
"TITLE" => title = line[5..].trim().trim_matches('"').to_string(),
"LUT_3D_SIZE" => size3 = it.next().and_then(|s| s.parse().ok()).ok_or_else(|| LutError("bad LUT_3D_SIZE".into()))?,
"LUT_1D_SIZE" => size1 = it.next().and_then(|s| s.parse().ok()).ok_or_else(|| LutError("bad LUT_1D_SIZE".into()))?,
"DOMAIN_MIN" => dmin = nums(it)?,
"DOMAIN_MAX" => dmax = nums(it)?,
"LUT_3D_INPUT_RANGE" | "LUT_1D_INPUT_RANGE" => {
let v: Vec<f32> = it.filter_map(|s| s.parse().ok()).collect();
if v.len() == 2 {
dmin = [v[0]; 3];
dmax = [v[1]; 3];
}
}
k if k.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '+' || c == '.') => {
let mut v = vec![k.parse::<f32>().map_err(|_| LutError(format!("bad number in `{line}`")))?];
v.extend(it.filter_map(|s| s.parse::<f32>().ok()));
if v.len() != 3 {
return err(format!("bad data line `{line}`"));
}
rows.extend(v);
}
// Unknown keywords (e.g. Resolve's LUT_IN_VIDEO_RANGE) are ignored, as the spec allows.
_ => {}
}
}
let domain_ok = (0..3).all(|k| dmin[k] < dmax[k] && dmin[k].is_finite() && dmax[k].is_finite());
if (size3 > 0 || size1 >= 2) && !domain_ok {
return err(format!("DOMAIN_MIN {:?} must lie below DOMAIN_MAX {:?}", dmin, dmax));
}
if size3 > 0 {
return LutFile { title, size: size3, data: rows, domain_min: dmin, domain_max: dmax }.check();
}
if size1 >= 2 {
let expected_rows = size1.checked_mul(3).ok_or_else(|| LutError(format!("unsupported 1D LUT size {size1}")))?;
if rows.len() != expected_rows {
return err("no LUT_3D_SIZE in .cube file");
}
// The 1D table is resampled onto a 0..1 cube anyway, so its input domain
// (`DOMAIN_MIN`/`MAX`, `LUT_1D_INPUT_RANGE`) is baked into the resample: an input `v`
// reads the table at `(v - min) / (max - min)`, saturating outside.
let curve = |ch: usize, v: f32| {
let t = (v - dmin[ch]) / (dmax[ch] - dmin[ch]);
let x = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 } * (size1 - 1) as f32;
let i = (x.floor() as usize).min(size1 - 2);
let f = x - i as f32;
rows[i * 3 + ch] * (1.0 - f) + rows[(i + 1) * 3 + ch] * f
};
let mut l = LutFile::from_fn(&title, 33, |c| [curve(0, c[0]), curve(1, c[1]), curve(2, c[2])]);
l.title = title;
return l.check();
}
err("no LUT_3D_SIZE in .cube file")
}
/// Lustre `.3dl`: an optional shaper line of input values, then integer RGB rows with **blue**
/// varying fastest; the output bit depth is inferred from the largest value.
pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
let mut rows: Vec<[f64; 3]> = Vec::new();
for line in text.lines() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() || line.starts_with(|c: char| c.is_ascii_alphabetic()) {
continue;
}
let v: Vec<f64> = line.split_whitespace().filter_map(|s| s.parse().ok()).collect();
if v.len() == 3 {
rows.push([v[0], v[1], v[2]]);
}
// Lines with more than three numbers are the input shaper; it is assumed linear.
}
let n = (rows.len() as f64).cbrt().round() as usize;
if n < 2 || n * n * n != rows.len() {
return err(format!("{} rows is not a cube", rows.len()));
}
let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
let scale = if max <= 1.0 { 1.0 } else { [1023.0, 4095.0, 16383.0, 65535.0].into_iter().find(|&s| max <= s).unwrap_or(max) };
let mut data = vec![0.0f32; n * n * n * 3];
for (i, row) in rows.iter().enumerate() {
let (r, g, b) = (i / (n * n), (i / n) % n, i % n);
let at = ((b * n + g) * n + r) * 3;
for k in 0..3 {
data[at + k] = (row[k] / scale) as f32;
}
}
LutFile { title: String::new(), size: n, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check()
}
/// SpeedGrade `.look`: `<size>` and a hex `<data>` string of little-endian float32 RGB triplets
/// (red fastest).
pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
let tag = |name: &str| -> Option<String> {
let open = format!("<{name}>");
let start = text.find(&open)? + open.len();
let end = text[start..].find(&format!("</{name}>"))? + start;
Some(text[start..end].trim().trim_matches('"').trim().to_string())
};
let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no <size> in .look".into()))?;
let (entries, _) = checked_data_len(size)?;
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect();
let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8;
let bytes: Vec<u8> = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect();
let floats: Vec<f32> = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
let rgba_entries = entries.checked_mul(4).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
let data = if floats.len() == rgba_entries { floats.as_chunks::<4>().0.iter().flat_map(|c| [c[0], c[1], c[2]]).collect() } else { floats };
LutFile { title: tag("title").unwrap_or_default(), size, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check()
}
/// Writes a `.cube` file (what Photoshop embeds in a Color Lookup layer).
pub fn write_cube(l: &LutFile) -> String {
let mut s = String::new();
if !l.title.is_empty() {
s.push_str(&format!("TITLE \"{}\"\n", l.title.replace('"', "'")));
}
s.push_str(&format!("LUT_3D_SIZE {}\n", l.size));
for c in l.data.as_chunks::<3>().0 {
s.push_str(&format!("{:.6} {:.6} {:.6}\n", c[0], c[1], c[2]));
}
s
}
fn luma(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}
fn smooth(x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
/// Generated looks shipped with this crate (procedural, CC0): `(id, label)`.
pub const BUILTIN: [(&str, &str); 8] = [
("warm", "Warm Filter"),
("cool", "Cool Filter"),
("tealOrange", "Teal & Orange"),
("bleachBypass", "Bleach Bypass"),
("fadedFilm", "Faded Film"),
("dayForNight", "Day for Night"),
("monoContrast", "Mono High Contrast"),
("crossProcess", "Cross Process"),
];
/// A built-in look by id (33³), or None.
pub fn builtin(id: &str) -> Option<LutFile> {
let label = BUILTIN.iter().find(|b| b.0 == id)?.1;
let f: Box<dyn Fn([f32; 3]) -> [f32; 3]> = match id {
"warm" => Box::new(|c: [f32; 3]| [c[0] * 0.92 + 0.08, c[1] * 0.97 + 0.02, c[2] * 0.85]),
"cool" => Box::new(|c: [f32; 3]| [c[0] * 0.86, c[1] * 0.97 + 0.01, c[2] * 0.9 + 0.1]),
"tealOrange" => Box::new(|c: [f32; 3]| {
// Shadows toward teal, highlights toward orange, skin-ish midtones kept.
let l = luma(c);
let t = smooth(l);
let teal = [0.0, 0.5, 0.55];
let orange = [1.0, 0.62, 0.3];
std::array::from_fn(|k| {
let tint = teal[k] * (1.0 - t) + orange[k] * t;
c[k] * 0.75 + (tint * l * 1.1).min(1.0) * 0.25 + (smooth(c[k]) - c[k]) * 0.3
})
}),
"bleachBypass" => Box::new(|c: [f32; 3]| {
let l = luma(c);
let s = smooth(l);
std::array::from_fn(|k| {
let desat = c[k] * 0.45 + l * 0.55;
desat * 0.5 + s * 0.5
})
}),
"fadedFilm" => Box::new(|c: [f32; 3]| {
let l = luma(c);
let tint = [1.0, 0.96, 0.88];
std::array::from_fn(|k| (0.08 + (c[k] * 0.8 + l * 0.2) * 0.84) * tint[k] + 0.02)
}),
"dayForNight" => Box::new(|c: [f32; 3]| {
let l = luma(c);
[l * 0.35, l * 0.45 + c[1] * 0.05, l * 0.65 + c[2] * 0.15]
}),
"monoContrast" => Box::new(|c: [f32; 3]| [smooth(smooth(luma(c))); 3]),
_ /* crossProcess */ => Box::new(|c: [f32; 3]| [smooth(c[0]) * 1.05, c[1] * 1.1 - 0.03, c[2] * 0.7 + 0.15]),
};
Some(LutFile::from_fn(label, 33, f))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cube_domain_is_kept_and_validated() {
let base = "TITLE \"t\"
LUT_3D_SIZE 2
";
let rows = "0 0 0
0 0 1
0 1 0
0 1 1
1 0 0
1 0 1
1 1 0
1 1 1
";
// The default domain parses as default.
let d = parse_cube(&format!("{base}{rows}")).expect("default domain");
assert!(d.domain_is_default());
// DOMAIN_MIN/MAX are kept for the caller to act on.
let d = parse_cube(&format!(
"DOMAIN_MIN 0.1 0.2 0.3
DOMAIN_MAX 0.9 0.8 0.7
{base}{rows}"
))
.expect("custom domain");
assert_eq!(d.domain_min, [0.1, 0.2, 0.3]);
assert_eq!(d.domain_max, [0.9, 0.8, 0.7]);
assert!(!d.domain_is_default());
// A degenerate domain is a parse error, not a division by zero later.
let e = parse_cube(&format!(
"DOMAIN_MIN 0.5 0.5 0.5
DOMAIN_MAX 0.5 1 1
{base}{rows}"
))
.unwrap_err();
assert!(e.0.contains("below DOMAIN_MAX"), "{e}");
}
#[test]
fn cube_1d_domain_is_baked_into_the_resample() {
// An identity ramp over the domain 0.25..0.75: 0.25 maps to 0, 0.5 to 0.5, 0.75 to 1.
let table = "LUT_1D_SIZE 2\nLUT_1D_INPUT_RANGE 0.25 0.75\n0 0 0\n1 1 1\n";
let l = parse_cube(table).expect("1D with input range");
assert!(l.domain_is_default(), "the domain is baked in, so the cube is plain 0..1");
let at = |r: usize| l.data[r * 3];
// 33-point grid, red fastest: entry r on the red axis is input r / 32.
assert!(at(0).abs() < 1e-6, "below the domain saturates to 0");
assert!((at(16) - 0.5).abs() < 1e-5, "0.5 is the middle of 0.25..0.75: {}", at(16));
assert!((at(8) - 0.0).abs() < 1e-5, "0.25 maps to 0: {}", at(8));
assert!((at(24) - 1.0).abs() < 1e-5, "0.75 maps to 1: {}", at(24));
assert!((at(32) - 1.0).abs() < 1e-6, "above the domain saturates to 1");
// Per-channel DOMAIN_MIN/MAX work the same way.
let l = parse_cube("LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0.5\nDOMAIN_MAX 1 1 1\n0 0 0\n1 1 1\n").expect("1D domain");
let blue_mid = l.data[(16 * 33 * 33) * 3 + 2];
assert!(blue_mid.abs() < 1e-5, "blue 0.5 is the bottom of its domain: {blue_mid}");
// A degenerate 1D domain is rejected like the 3D one.
let e = parse_cube("LUT_1D_SIZE 2\nLUT_1D_INPUT_RANGE 1 1\n0 0 0\n1 1 1\n").unwrap_err();
assert!(e.0.contains("below DOMAIN_MAX"), "{e}");
}
#[test]
fn cube_roundtrip_and_identity() {
let id = LutFile::identity(5);
let text = write_cube(&LutFile { title: "Id".into(), ..id.clone() });
let back = parse_cube(&text).unwrap();
assert_eq!(back.size, 5);
assert_eq!(back.title, "Id");
for (a, b) in back.data.iter().zip(&id.data) {
assert!((a - b).abs() < 1e-5);
}
// Red varies fastest: entry 1 is (0.25, 0, 0).
assert_eq!(&back.data[3..6], &[0.25, 0.0, 0.0]);
}
#[test]
fn cube_1d_expands() {
let text = "LUT_1D_SIZE 2\n0 0 0\n0.5 1 1\n";
let l = parse_cube(text).unwrap();
assert_eq!(l.size, 33);
let last = &l.data[l.data.len() - 3..];
assert!((last[0] - 0.5).abs() < 1e-6 && (last[1] - 1.0).abs() < 1e-6);
let mut larger = String::from("LUT_1D_SIZE 256\n");
for i in 0..256 {
let value = i as f32 / 255.0;
larger.push_str(&format!("{value} {value} {value}\n"));
}
assert_eq!(parse_cube(&larger).unwrap().size, 33);
}
#[test]
fn cube_1d_size_multiplication_overflow_is_rejected() {
let text = format!("LUT_1D_SIZE {}\n", usize::MAX);
assert!(parse_cube(&text).is_err());
}
#[test]
fn three_dl_blue_fastest_and_bit_depth() {
let mut text = String::from("0 1023\n");
for r in 0..2 {
for g in 0..2 {
for b in 0..2 {
text.push_str(&format!("{} {} {}\n", r * 4095, g * 4095, b * 4095));
}
}
}
let l = parse_3dl(&text).unwrap();
assert_eq!(l.size, 2);
assert_eq!(l.data, LutFile::identity(2).data);
}
#[test]
fn look_hex_floats() {
let id = LutFile::identity(2);
let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
let xml = format!("<?xml version=\"1.0\"?><look><LUT><size>\"2\"</size><data>\"{hex}\"</data></LUT></look>");
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
}
#[test]
fn look_3d_size_exponent_overflow_is_rejected() {
let text = format!("<look><size>{}</size><data>00</data></look>", usize::MAX);
assert!(parse_look(&text).is_err());
}
#[test]
fn malformed_rejected() {
assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err());
assert!(parse_cube("garbage").is_err());
assert!(parse_cube("LUT_3D_SIZE 99999\n").is_err());
assert!(parse_3dl("1 2 3\n4 5 6\n").is_err());
assert!(parse_look("<look></look>").is_err());
}
#[test]
fn builtins_exist_and_are_in_range() {
for (id, _) in BUILTIN {
let l = builtin(id).unwrap();
assert_eq!(l.data.len(), 33 * 33 * 33 * 3);
assert!(l.data.iter().all(|v| (0.0..=1.0).contains(v)), "{id}");
}
assert!(builtin("nope").is_none());
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Small colour-science helpers: 3×3 matrices, CIE XYZ ↔ L*a*b*, chromatic adaptation.
//!
//! All formulas are from public standards: CIE 15 (L*a*b*), ICC.1:2010 Annex A (PCS, D50),
//! and the Bradford cone response matrix published by Lam (1985).
/// The ICC profile connection space illuminant (D50), as encoded in s15Fixed16 in every
/// ICC header: X = 0.9642, Y = 1.0, Z = 0.8249.
pub const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
/// CIE D65 white point as XYZ (Y = 1), from the chromaticity (0.3127, 0.3290) used by sRGB,
/// Display P3 and Adobe RGB-compatible spaces.
pub const D65_XY: [f64; 2] = [0.3127, 0.3290];
/// Row-major 3×3 matrix.
pub type Mat3 = [[f64; 3]; 3];
pub const IDENTITY: Mat3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
pub fn mul(a: &Mat3, b: &Mat3) -> Mat3 {
let mut o = [[0.0; 3]; 3];
for (i, row) in o.iter_mut().enumerate() {
for (j, v) in row.iter_mut().enumerate() {
*v = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
}
}
o
}
pub const fn apply(m: &Mat3, v: [f64; 3]) -> [f64; 3] {
[
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
]
}
pub const fn det(m: &Mat3) -> f64 {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
/// Inverse, or `None` when (nearly) singular.
pub const fn invert(m: &Mat3) -> Option<Mat3> {
let d = det(m);
if !d.is_finite() || d.abs() < 1e-12 {
return None;
}
let inv = 1.0 / d;
Some([
[(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv],
[(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv],
[(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv],
])
}
/// XYZ (Y = 1) of a chromaticity `(x, y)`.
pub const fn xy_to_xyz(xy: [f64; 2]) -> [f64; 3] {
let [x, y] = xy;
[x / y, 1.0, (1.0 - x - y) / y]
}
/// Bradford cone response matrix (Lam 1985).
const BRADFORD: Mat3 = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]];
/// Inverse of [`BRADFORD`] (determinant ~1.7), computed at compile time.
const BRADFORD_INV: Mat3 = match invert(&BRADFORD) {
Some(m) => m,
None => panic!("the Bradford matrix is invertible"),
};
/// Bradford chromatic adaptation matrix from white `src` to white `dst` (both XYZ).
pub fn bradford(src: [f64; 3], dst: [f64; 3]) -> Mat3 {
let inv = BRADFORD_INV;
let s = apply(&BRADFORD, src);
let d = apply(&BRADFORD, dst);
let scale = [[d[0] / s[0], 0.0, 0.0], [0.0, d[1] / s[1], 0.0], [0.0, 0.0, d[2] / s[2]]];
mul(&inv, &mul(&scale, &BRADFORD))
}
/// RGB → XYZ matrix (relative to `white`, Y = 1) for primaries given as chromaticities, or
/// `None` when the primaries are not independent.
pub const fn rgb_to_xyz_matrix(r: [f64; 2], g: [f64; 2], b: [f64; 2], white: [f64; 3]) -> Option<Mat3> {
let (xr, xg, xb) = (xy_to_xyz(r), xy_to_xyz(g), xy_to_xyz(b));
let m = [[xr[0], xg[0], xb[0]], [xr[1], xg[1], xb[1]], [xr[2], xg[2], xb[2]]];
let Some(inv) = invert(&m) else { return None };
let s = apply(&inv, white);
Some([
[m[0][0] * s[0], m[0][1] * s[1], m[0][2] * s[2]],
[m[1][0] * s[0], m[1][1] * s[1], m[1][2] * s[2]],
[m[2][0] * s[0], m[2][1] * s[1], m[2][2] * s[2]],
])
}
const EPS: f64 = 216.0 / 24389.0; // (6/29)^3
const KAPPA: f64 = 24389.0 / 27.0; // (29/3)^3
#[inline]
fn lab_f(t: f64) -> f64 {
if t > EPS { t.cbrt() } else { (KAPPA * t + 16.0) / 116.0 }
}
#[inline]
fn lab_finv(f: f64) -> f64 {
let f3 = f * f * f;
if f3 > EPS { f3 } else { (116.0 * f - 16.0) / KAPPA }
}
/// CIE XYZ → L*a*b* relative to `white` (CIE 15).
pub fn xyz_to_lab(xyz: [f64; 3], white: [f64; 3]) -> [f64; 3] {
let fx = lab_f(xyz[0] / white[0]);
let fy = lab_f(xyz[1] / white[1]);
let fz = lab_f(xyz[2] / white[2]);
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
/// CIE L*a*b* → XYZ relative to `white`.
pub fn lab_to_xyz(lab: [f64; 3], white: [f64; 3]) -> [f64; 3] {
let fy = (lab[0] + 16.0) / 116.0;
let fx = fy + lab[1] / 500.0;
let fz = fy - lab[2] / 200.0;
[lab_finv(fx) * white[0], lab_finv(fy) * white[1], lab_finv(fz) * white[2]]
}
/// f32 versions for the hot paths (D50 PCS only).
#[inline]
pub fn xyz_to_lab_f32(xyz: [f32; 3]) -> [f32; 3] {
const W: [f32; 3] = [0.9642, 1.0, 0.8249];
const E: f32 = 216.0 / 24389.0;
const K: f32 = 24389.0 / 27.0;
let f = |t: f32| if t > E { t.cbrt() } else { (K * t + 16.0) / 116.0 };
let fx = f(xyz[0] / W[0]);
let fy = f(xyz[1] / W[1]);
let fz = f(xyz[2] / W[2]);
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
#[inline]
pub fn lab_to_xyz_f32(lab: [f32; 3]) -> [f32; 3] {
const W: [f32; 3] = [0.9642, 1.0, 0.8249];
const E: f32 = 216.0 / 24389.0;
const K: f32 = 24389.0 / 27.0;
let fy = (lab[0] + 16.0) / 116.0;
let fx = fy + lab[1] / 500.0;
let fz = fy - lab[2] / 200.0;
let finv = |f: f32| {
let f3 = f * f * f;
if f3 > E { f3 } else { (116.0 * f - 16.0) / K }
};
[finv(fx) * W[0], finv(fy) * W[1], finv(fz) * W[2]]
}
/// CIE 1976 colour difference.
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()
}
/// 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
}
/// The D50 illuminant exactly as an ICC header stores it (s15Fixed16).
pub fn d50_quantized() -> [f64; 3] {
[s15f16_round(D50[0]), s15f16_round(D50[1]), s15f16_round(D50[2])]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lab_roundtrip() {
for xyz in [[0.2, 0.3, 0.1], [0.9642, 1.0, 0.8249], [0.001, 0.002, 0.0005]] {
let back = lab_to_xyz(xyz_to_lab(xyz, D50), D50);
for i in 0..3 {
assert!((back[i] - xyz[i]).abs() < 1e-12);
}
}
let w = xyz_to_lab(D50, D50);
assert!((w[0] - 100.0).abs() < 1e-12 && w[1].abs() < 1e-12 && w[2].abs() < 1e-12);
}
#[test]
fn bradford_maps_white() {
let d65 = xy_to_xyz(D65_XY);
let m = bradford(d65, D50);
let w = apply(&m, d65);
for i in 0..3 {
assert!((w[i] - D50[i]).abs() < 1e-12);
}
}
#[test]
fn invert_roundtrip() {
let m = [[0.4, 0.3, 0.2], [0.2, 0.7, 0.1], [0.0, 0.1, 0.8]];
let p = mul(&m, &invert(&m).unwrap());
for (i, row) in p.iter().enumerate() {
for (j, v) in row.iter().enumerate() {
assert!((v - if i == j { 1.0 } else { 0.0 }).abs() < 1e-12);
}
}
}
}
+241
View File
@@ -0,0 +1,241 @@
//! Float evaluation pipelines: a list of stages mapping normalised device values to the
//! profile connection space (or back). This is the exact reference path; [`crate::Transform`]
//! builds faster lookup tables from it.
use crate::clut::Clut;
use crate::curve::Curve;
use crate::math;
/// Largest channel count flowing through a pipeline.
pub const MAX: usize = 16;
/// One processing element.
#[derive(Clone, Debug, PartialEq)]
pub enum Stage {
/// Per-channel curves.
Curves(Vec<Curve>),
/// Per-channel inverse curves.
InvCurves(Vec<Curve>),
/// `out[r] = Σ m[r][c]·in[c] + offset[r]`, `rows × cols`.
Matrix {
rows: usize,
cols: usize,
m: Vec<f64>,
offset: Vec<f64>,
},
Clut(Clut),
/// Float CIE Lab (L 0–100) → XYZ (D50, Y = 1).
LabToXyz,
XyzToLab,
/// ICC v4 / 8-bit Lab encoding (normalised) → float Lab, and back.
DecodeLabV4,
EncodeLabV4,
/// ICC v2 legacy 16-bit Lab encoding (used by `lut16Type`) → float Lab, and back.
DecodeLabV2,
EncodeLabV2,
/// u1Fixed15 XYZ encoding (normalised over 0..=0xFFFF) → XYZ, and back.
DecodeXyz,
EncodeXyz,
/// Clamp every channel to `[0, 1]`.
Clamp01,
}
const XYZ_SCALE: f32 = 65535.0 / 32768.0;
const V2_L: f32 = 100.0 * 65535.0 / 65280.0;
const V2_AB: f32 = 255.0 * 65535.0 / 65280.0;
impl Stage {
/// Output channel count given `n` inputs.
pub fn outputs(&self, n: usize) -> usize {
match self {
Stage::Matrix { rows, .. } => *rows,
Stage::Clut(c) => c.outputs,
_ => n,
}
}
pub fn matrix3(m: &math::Mat3) -> Stage {
Stage::Matrix { rows: 3, cols: 3, m: m.iter().flatten().copied().collect(), offset: vec![0.0; 3] }
}
#[inline]
fn run(&self, v: &mut [f32; MAX], n: usize) -> usize {
match self {
Stage::Curves(cs) => {
for (x, c) in v.iter_mut().zip(cs) {
*x = c.eval(*x);
}
n
}
Stage::InvCurves(cs) => {
for (x, c) in v.iter_mut().zip(cs) {
*x = c.eval_inverse(*x);
}
n
}
Stage::Matrix { rows, cols, m, offset } => {
let mut o = [0.0f32; MAX];
for r in 0..*rows {
let mut s = offset[r];
for c in 0..*cols {
s += m[r * cols + c] * v[c] as f64;
}
o[r] = s as f32;
}
v[..*rows].copy_from_slice(&o[..*rows]);
*rows
}
Stage::Clut(c) => {
let mut o = [0.0f32; MAX];
let mut inp = [0.0f32; MAX];
inp[..n].copy_from_slice(&v[..n]);
c.eval(&inp[..c.inputs], &mut o[..c.outputs]);
v[..c.outputs].copy_from_slice(&o[..c.outputs]);
c.outputs
}
Stage::LabToXyz => {
let x = math::lab_to_xyz_f32([v[0], v[1], v[2]]);
v[..3].copy_from_slice(&x);
3
}
Stage::XyzToLab => {
let l = math::xyz_to_lab_f32([v[0].max(0.0), v[1].max(0.0), v[2].max(0.0)]);
v[..3].copy_from_slice(&l);
3
}
Stage::DecodeLabV4 => {
v[0] *= 100.0;
v[1] = v[1] * 255.0 - 128.0;
v[2] = v[2] * 255.0 - 128.0;
3
}
Stage::EncodeLabV4 => {
v[0] = (v[0] / 100.0).clamp(0.0, 1.0);
v[1] = ((v[1] + 128.0) / 255.0).clamp(0.0, 1.0);
v[2] = ((v[2] + 128.0) / 255.0).clamp(0.0, 1.0);
3
}
Stage::DecodeLabV2 => {
v[0] *= V2_L;
v[1] = v[1] * V2_AB - 128.0;
v[2] = v[2] * V2_AB - 128.0;
3
}
Stage::EncodeLabV2 => {
v[0] = (v[0] / V2_L).clamp(0.0, 1.0);
v[1] = ((v[1] + 128.0) / V2_AB).clamp(0.0, 1.0);
v[2] = ((v[2] + 128.0) / V2_AB).clamp(0.0, 1.0);
3
}
Stage::DecodeXyz => {
for x in &mut v[..3] {
*x *= XYZ_SCALE;
}
3
}
Stage::EncodeXyz => {
for x in &mut v[..3] {
*x = (*x / XYZ_SCALE).clamp(0.0, 1.0);
}
3
}
Stage::Clamp01 => {
for x in &mut v[..n] {
*x = x.clamp(0.0, 1.0);
}
n
}
}
}
}
/// A sequence of stages with fixed input and output channel counts.
#[derive(Clone, Debug, PartialEq)]
pub struct Pipeline {
pub inputs: usize,
pub outputs: usize,
pub stages: Vec<Stage>,
}
impl Pipeline {
pub fn new(inputs: usize, stages: Vec<Stage>) -> Self {
let outputs = stages.iter().fold(inputs, |n, s| s.outputs(n));
Pipeline { inputs, outputs, stages }
}
/// Appends stages.
pub fn extend(&mut self, stages: impl IntoIterator<Item = Stage>) {
for s in stages {
self.outputs = s.outputs(self.outputs);
self.stages.push(s);
}
}
/// Evaluates one pixel: `input` has `inputs` values, `out` receives `outputs` values.
#[inline]
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
let mut v = [0.0f32; MAX];
v[..self.inputs].copy_from_slice(&input[..self.inputs]);
let mut n = self.inputs;
for s in &self.stages {
n = s.run(&mut v, n);
}
out[..self.outputs].copy_from_slice(&v[..self.outputs]);
}
/// Removes no-op stages and cancels adjacent inverse pairs (Lab↔XYZ, encode/decode).
pub fn optimize(&mut self) {
let mut out: Vec<Stage> = Vec::with_capacity(self.stages.len());
for s in self.stages.drain(..) {
let noop = match &s {
Stage::Curves(c) | Stage::InvCurves(c) => c.iter().all(Curve::is_identity),
Stage::Matrix { rows: 3, cols: 3, m, offset } => {
offset.iter().all(|o| o.abs() < 1e-12)
&& m.iter().enumerate().all(|(i, v)| (v - if i % 4 == 0 { 1.0 } else { 0.0 }).abs() < 1e-12)
}
_ => false,
};
if noop {
continue;
}
let cancels = matches!(
(out.last(), &s),
(Some(Stage::LabToXyz), Stage::XyzToLab)
| (Some(Stage::XyzToLab), Stage::LabToXyz)
| (Some(Stage::EncodeLabV4), Stage::DecodeLabV4)
| (Some(Stage::EncodeLabV2), Stage::DecodeLabV2)
| (Some(Stage::EncodeXyz), Stage::DecodeXyz)
);
// Decode→encode of the same encoding is only exact inside the gamut of the encoding,
// which always holds for data coming out of a table, so those pairs cancel too.
let cancels_dec = matches!(
(out.last(), &s),
(Some(Stage::DecodeLabV4), Stage::EncodeLabV4)
| (Some(Stage::DecodeLabV2), Stage::EncodeLabV2)
| (Some(Stage::DecodeXyz), Stage::EncodeXyz)
);
if cancels || cancels_dec {
out.pop();
continue;
}
// Merge consecutive 3×3 matrices.
if let (Some(Stage::Matrix { rows: 3, cols: 3, m: a, offset: oa }), Stage::Matrix { rows: 3, cols: 3, m: b, offset: ob }) =
(out.last(), &s)
{
let mut m = vec![0.0; 9];
let mut off = vec![0.0; 3];
for r in 0..3 {
for c in 0..3 {
m[r * 3 + c] = (0..3).map(|k| b[r * 3 + k] * a[k * 3 + c]).sum();
}
off[r] = (0..3).map(|k| b[r * 3 + k] * oa[k]).sum::<f64>() + ob[r];
}
out.pop();
out.push(Stage::Matrix { rows: 3, cols: 3, m, offset: off });
continue;
}
out.push(s);
}
self.stages = out;
}
}
+794
View File
@@ -0,0 +1,794 @@
//! ICC profiles: the parsed model, the reader (ICC.1:2001-04 v2 and ICC.1:2010 v4) and the
//! connection of a profile to the PCS as evaluation stages.
use std::sync::Arc;
use crate::clut::Clut;
use crate::curve::Curve;
use crate::math::{self, Mat3};
use crate::pipeline::Stage;
use crate::{CmsError, Intent};
/// Profile/device class (header bytes 12..16).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ProfileClass {
Input,
Display,
Output,
DeviceLink,
ColorSpace,
Abstract,
NamedColor,
Unknown(u32),
}
impl ProfileClass {
pub fn from_sig(s: u32) -> Self {
match &s.to_be_bytes() {
b"scnr" => Self::Input,
b"mntr" => Self::Display,
b"prtr" => Self::Output,
b"link" => Self::DeviceLink,
b"spac" => Self::ColorSpace,
b"abst" => Self::Abstract,
b"nmcl" => Self::NamedColor,
_ => Self::Unknown(s),
}
}
pub fn sig(self) -> u32 {
u32::from_be_bytes(*match self {
Self::Input => b"scnr",
Self::Display => b"mntr",
Self::Output => b"prtr",
Self::DeviceLink => b"link",
Self::ColorSpace => b"spac",
Self::Abstract => b"abst",
Self::NamedColor => b"nmcl",
Self::Unknown(s) => return s,
})
}
}
/// Data colour space (header bytes 16..20).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ColorSpace {
Xyz,
Lab,
Luv,
YCbCr,
Yxy,
Rgb,
Gray,
Hsv,
Hls,
Cmyk,
Cmy,
/// `nCLR` spaces with n = 2..=15 colorants.
Color(u8),
Unknown(u32),
}
impl ColorSpace {
pub fn from_sig(s: u32) -> Self {
let b = s.to_be_bytes();
match &b {
b"XYZ " => Self::Xyz,
b"Lab " => Self::Lab,
b"Luv " => Self::Luv,
b"YCbr" => Self::YCbCr,
b"Yxy " => Self::Yxy,
b"RGB " => Self::Rgb,
b"GRAY" => Self::Gray,
b"HSV " => Self::Hsv,
b"HLS " => Self::Hls,
b"CMYK" => Self::Cmyk,
b"CMY " => Self::Cmy,
[d, b'C', b'L', b'R'] => match *d {
b'2'..=b'9' => Self::Color(d - b'0'),
b'A'..=b'F' => Self::Color(d - b'A' + 10),
_ => Self::Unknown(s),
},
_ => Self::Unknown(s),
}
}
pub fn sig(self) -> u32 {
u32::from_be_bytes(match self {
Self::Xyz => *b"XYZ ",
Self::Lab => *b"Lab ",
Self::Luv => *b"Luv ",
Self::YCbCr => *b"YCbr",
Self::Yxy => *b"Yxy ",
Self::Rgb => *b"RGB ",
Self::Gray => *b"GRAY",
Self::Hsv => *b"HSV ",
Self::Hls => *b"HLS ",
Self::Cmyk => *b"CMYK",
Self::Cmy => *b"CMY ",
Self::Color(n) => [if n < 10 { b'0' + n } else { b'A' + n - 10 }, b'C', b'L', b'R'],
Self::Unknown(s) => return s,
})
}
/// Number of channels of the space.
pub fn channels(self) -> usize {
match self {
Self::Gray => 1,
Self::Cmyk => 4,
Self::Color(n) => n as usize,
Self::Unknown(_) => 0,
_ => 3,
}
}
}
/// Which PCS a stage list ends in (float values: XYZ with Y = 1 for the white, or Lab L 0–100).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pcs {
Xyz,
Lab,
}
/// Encoding family of a LUT tag, which fixes how its PCS side is encoded.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LutKind {
/// `mft1` (8-bit), v4-style Lab encoding.
Lut8,
/// `mft2` (16-bit), legacy v2 Lab encoding.
Lut16,
/// `mAB ` / `mBA ` (v4), v4 Lab encoding.
Ab,
}
/// A parsed `AToB`/`BToA` tag: device-side and PCS-side values are normalised to `[0, 1]`.
#[derive(Clone, Debug, PartialEq)]
pub struct Lut {
pub kind: LutKind,
pub inputs: usize,
pub outputs: usize,
pub stages: Vec<Stage>,
}
/// Largest colour difference (CIE ΔE*ab) for which [`Profile::same_colors`] treats two profiles
/// as one colour space: invisible, yet 20× what re-encoding a profile costs (sRGB as v2 tables
/// against v4 parametric curves differs by 0.025). Moving sRGB's red primary 2% costs 1.0.
pub const SAME_COLORS_MAX_DELTA_E: f64 = 0.5;
/// A parsed ICC profile.
#[derive(Clone, Debug, PartialEq)]
pub struct Profile {
/// Major version and minor.bugfix byte (e.g. `(4, 0x30)` for 4.3, `(2, 0x10)` for 2.1).
pub version: (u8, u8),
pub class: ProfileClass,
pub color_space: ColorSpace,
pub pcs: Pcs,
pub rendering_intent: Intent,
pub description: String,
pub copyright: String,
/// `wtpt` as stored (XYZ; D50 when absent).
pub white_point: [f64; 3],
/// `bkpt` when present.
pub black_point: Option<[f64; 3]>,
/// `chad` chromatic adaptation matrix when present.
pub chad: Option<Mat3>,
/// Colorant matrix (columns rXYZ, gXYZ, bXYZ), D50-adapted.
pub matrix: Option<Mat3>,
pub trc: Option<[Curve; 3]>,
pub gray_trc: Option<Curve>,
/// A2B0/A2B1/A2B2 (perceptual, colorimetric, saturation).
pub a2b: [Option<Lut>; 3],
pub b2a: [Option<Lut>; 3],
/// The bytes this profile was parsed from (or encoded to), kept for byte-exact embedding.
pub(crate) bytes: Option<Arc<Vec<u8>>>,
pub(crate) hash: std::sync::OnceLock<u64>,
}
impl Profile {
/// Parses ICC profile bytes.
pub fn parse(bytes: &[u8]) -> Result<Profile, CmsError> {
let mut p = parse_profile(bytes)?;
p.bytes = Some(Arc::new(bytes.to_vec()));
Ok(p)
}
/// The ICC bytes of this profile: the original bytes when it was parsed, otherwise a fresh
/// ICC v4 encoding.
pub fn to_bytes(&self) -> Arc<Vec<u8>> {
match &self.bytes {
Some(b) => b.clone(),
None => Arc::new(crate::write::encode(self)),
}
}
/// Re-encodes the model as ICC v4 bytes and caches them (used for built-in profiles).
pub fn with_encoded_bytes(mut self) -> Profile {
self.bytes = None;
self.hash = Default::default();
let b = crate::write::encode(&self);
self.bytes = Some(Arc::new(b));
self
}
pub fn channels(&self) -> usize {
self.color_space.channels()
}
/// Hash of the profile's ICC bytes (stable within a process; used as a cache key).
pub fn content_hash(&self) -> u64 {
*self.hash.get_or_init(|| {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
self.to_bytes().hash(&mut h);
h.finish()
})
}
/// Whether `self` and `other` describe the same colours: same colour space, and every sample
/// of a device-value grid has the same colour through both (relative colorimetric, within
/// [`SAME_COLORS_MAX_DELTA_E`]). Different encodings of one space are the same colours: the
/// sRGB IEC61966-2.1 Photoshop embeds (v2, 1024-entry tables) and the built-in sRGB (v4,
/// parametric curves) differ in every byte. Colours are compared rather than round-tripped
/// because CMYK → PCS → CMYK is not an identity even through a single profile.
pub fn same_colors(&self, other: &Profile) -> bool {
if self.content_hash() == other.content_hash() {
return true;
}
let n = self.channels();
if self.color_space != other.color_space || n == 0 || n > 4 {
return false;
}
let lab = crate::builtin::Builtin::LabD50.profile();
let to_lab = |p: &Profile| crate::transform::Transform::new(p, lab, Intent::RelativeColorimetric, false);
let (Ok(a), Ok(b)) = (to_lab(self), to_lab(other)) else {
return false;
};
// 17 steps per channel catch a TRC that differs only in its toe; 9 keep CMYK at 6561 samples.
let steps: usize = if n <= 3 { 17 } else { 9 };
// Lab D50 is the PCS, normalised as in ICC v4.
let decode = |o: [f32; 3]| [f64::from(o[0]) * 100.0, f64::from(o[1]) * 255.0 - 128.0, f64::from(o[2]) * 255.0 - 128.0];
let mut input = [0f32; 4];
(0..steps.pow(n as u32)).all(|i| {
let mut k = i;
for v in input.iter_mut().take(n) {
*v = (k % steps) as f32 / (steps - 1) as f32;
k /= steps;
}
let (mut la, mut lb) = ([0f32; 3], [0f32; 3]);
a.eval(&input[..n], &mut la);
b.eval(&input[..n], &mut lb);
math::delta_e76(decode(la), decode(lb)) <= SAME_COLORS_MAX_DELTA_E
})
}
/// Media white point used for ICC-absolute colorimetry: `wtpt`, except for v2 display
/// profiles whose `wtpt` records the (unadapted) display white; those are treated as D50.
pub fn media_white(&self) -> [f64; 3] {
if self.version.0 < 4 && self.class == ProfileClass::Display {
return math::D50;
}
self.white_point
}
/// An RGB view of a gray TRC profile: each of R, G, B carries the gray curve and maps to a
/// third of the white, so `(v, v, v)` has exactly the colour of gray `v`. Used for
/// composites of gray documents, which are stored as replicated RGB.
pub fn gray_as_rgb(&self) -> Option<Profile> {
let c = self.gray_trc.clone()?;
if self.pcs != Pcs::Xyz {
return None;
}
let w = math::D50;
let m = [[w[0] / 3.0; 3], [w[1] / 3.0; 3], [w[2] / 3.0; 3]];
let mut p = self.clone();
p.color_space = ColorSpace::Rgb;
p.description = format!("{} (as RGB)", self.description);
p.matrix = Some(m);
p.trc = Some([c.clone(), c.clone(), c]);
p.gray_trc = None;
// The gray LUTs take one input channel; this profile is the matrix/TRC model above.
p.a2b = Default::default();
p.b2a = Default::default();
p.bytes = None;
p.hash = Default::default();
Some(p)
}
/// `true` for matrix/TRC RGB or gray TRC profiles without LUTs.
pub fn is_matrix_shaper(&self) -> bool {
self.a2b.iter().all(Option::is_none) && (self.matrix.is_some() && self.trc.is_some() || self.gray_trc.is_some())
}
/// Whether colours can be converted into this profile for every intent. Input profiles with
/// only AToB tables (Apple's Wide Color Sharing Profile on iPhone photos) can't: they
/// describe a source but have no PCS → device path.
pub fn can_be_destination(&self) -> bool {
[Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation].into_iter().all(|i| self.pcs_to_device(i).is_ok())
}
fn lut_for(luts: &[Option<Lut>; 3], intent: Intent) -> Option<&Lut> {
let i = match intent {
Intent::Perceptual => 0,
Intent::RelativeColorimetric | Intent::AbsoluteColorimetric => 1,
Intent::Saturation => 2,
};
luts[i].as_ref().or(luts[0].as_ref())
}
/// Whether the profile has a dedicated table for `intent` (matrix profiles support every
/// intent colorimetrically).
pub fn supports_intent(&self, intent: Intent, input: bool) -> bool {
let luts = if input { &self.a2b } else { &self.b2a };
let i = match intent {
Intent::Perceptual => 0,
Intent::RelativeColorimetric | Intent::AbsoluteColorimetric => 1,
Intent::Saturation => 2,
};
luts[i].is_some() || luts.iter().all(Option::is_none)
}
/// Stages mapping normalised device values to the float PCS.
pub fn device_to_pcs(&self, intent: Intent) -> Result<(Vec<Stage>, Pcs), CmsError> {
if let Some(l) = Self::lut_for(&self.a2b, intent) {
let mut st = l.stages.clone();
st.push(decode_pcs(self.pcs, l.kind));
return Ok((st, self.pcs));
}
match self.color_space {
ColorSpace::Rgb => {
let (Some(m), Some(trc)) = (&self.matrix, &self.trc) else {
return Err(CmsError::Unsupported("RGB profile without matrix/TRC or AToB tags".into()));
};
Ok((vec![Stage::Curves(trc.to_vec()), Stage::matrix3(m)], Pcs::Xyz))
}
ColorSpace::Gray => {
let c = self.gray_trc.clone().ok_or_else(|| CmsError::Unsupported("gray profile without kTRC".into()))?;
if self.pcs == Pcs::Lab {
// The curve yields L*/100.
let m = Stage::Matrix { rows: 3, cols: 1, m: vec![100.0, 0.0, 0.0], offset: vec![0.0; 3] };
Ok((vec![Stage::Curves(vec![c]), m], Pcs::Lab))
} else {
let w = math::D50;
let m = Stage::Matrix { rows: 3, cols: 1, m: w.to_vec(), offset: vec![0.0; 3] };
Ok((vec![Stage::Curves(vec![c]), m], Pcs::Xyz))
}
}
ColorSpace::Lab => Ok((vec![Stage::DecodeLabV4], Pcs::Lab)),
ColorSpace::Xyz => Ok((vec![Stage::DecodeXyz], Pcs::Xyz)),
cs => Err(CmsError::Unsupported(format!("{cs:?} profile without AToB tags"))),
}
}
/// Stages mapping the float PCS (of the returned kind) to normalised device values.
pub fn pcs_to_device(&self, intent: Intent) -> Result<(Vec<Stage>, Pcs), CmsError> {
if let Some(l) = Self::lut_for(&self.b2a, intent) {
let mut st = vec![encode_pcs(self.pcs, l.kind)];
st.extend(l.stages.iter().cloned());
return Ok((st, self.pcs));
}
match self.color_space {
ColorSpace::Rgb => {
let (Some(m), Some(trc)) = (&self.matrix, &self.trc) else {
return Err(CmsError::Unsupported("RGB profile without matrix/TRC or BToA tags".into()));
};
let inv = math::invert(m).ok_or_else(|| CmsError::Invalid("singular colorant matrix".into()))?;
Ok((vec![Stage::matrix3(&inv), Stage::InvCurves(trc.to_vec())], Pcs::Xyz))
}
ColorSpace::Gray => {
let c = self.gray_trc.clone().ok_or_else(|| CmsError::Unsupported("gray profile without kTRC".into()))?;
if self.pcs == Pcs::Lab {
let m = Stage::Matrix { rows: 1, cols: 3, m: vec![0.01, 0.0, 0.0], offset: vec![0.0] };
Ok((vec![m, Stage::InvCurves(vec![c])], Pcs::Lab))
} else {
let m = Stage::Matrix { rows: 1, cols: 3, m: vec![0.0, 1.0, 0.0], offset: vec![0.0] };
Ok((vec![m, Stage::InvCurves(vec![c])], Pcs::Xyz))
}
}
ColorSpace::Lab => Ok((vec![Stage::EncodeLabV4], Pcs::Lab)),
ColorSpace::Xyz => Ok((vec![Stage::EncodeXyz], Pcs::Xyz)),
cs => Err(CmsError::Unsupported(format!("{cs:?} profile without BToA tags"))),
}
}
}
fn decode_pcs(pcs: Pcs, kind: LutKind) -> Stage {
match (pcs, kind) {
(Pcs::Xyz, _) => Stage::DecodeXyz,
(Pcs::Lab, LutKind::Lut16) => Stage::DecodeLabV2,
(Pcs::Lab, _) => Stage::DecodeLabV4,
}
}
fn encode_pcs(pcs: Pcs, kind: LutKind) -> Stage {
match (pcs, kind) {
(Pcs::Xyz, _) => Stage::EncodeXyz,
(Pcs::Lab, LutKind::Lut16) => Stage::EncodeLabV2,
(Pcs::Lab, _) => Stage::EncodeLabV4,
}
}
// ---------------------------------------------------------------- reader
struct Reader<'a> {
b: &'a [u8],
}
impl<'a> Reader<'a> {
fn slice(&self, off: usize, len: usize) -> Result<&'a [u8], CmsError> {
off.checked_add(len).and_then(|end| self.b.get(off..end)).ok_or(CmsError::Truncated)
}
fn u8(&self, off: usize) -> Result<u8, CmsError> {
self.b.get(off).copied().ok_or(CmsError::Truncated)
}
fn u16(&self, off: usize) -> Result<u16, CmsError> {
let s = self.slice(off, 2)?;
Ok(u16::from_be_bytes([s[0], s[1]]))
}
fn u32(&self, off: usize) -> Result<u32, CmsError> {
let s = self.slice(off, 4)?;
Ok(u32::from_be_bytes([s[0], s[1], s[2], s[3]]))
}
fn s15f16(&self, off: usize) -> Result<f64, CmsError> {
Ok(self.u32(off)? as i32 as f64 / 65536.0)
}
fn xyz(&self, off: usize) -> Result<[f64; 3], CmsError> {
Ok([self.s15f16(off)?, self.s15f16(off + 4)?, self.s15f16(off + 8)?])
}
}
fn sig(s: &[u8; 4]) -> u32 {
u32::from_be_bytes(*s)
}
/// Upper bound on CLUT nodes × outputs we accept, to reject hostile files.
const MAX_CLUT_VALUES: usize = 16 * 1024 * 1024;
fn parse_profile(bytes: &[u8]) -> Result<Profile, CmsError> {
let r = Reader { b: bytes };
if bytes.len() < 132 {
return Err(CmsError::Truncated);
}
if r.slice(36, 4)? != b"acsp" {
return Err(CmsError::BadSignature);
}
let declared = r.u32(0)? as usize;
// Some writers pad or truncate; trust the smaller of the two.
let end = if declared >= 132 && declared <= bytes.len() { declared } else { bytes.len() };
let r = Reader { b: &bytes[..end] };
let version = (r.u8(8)?, r.u8(9)?);
let class = ProfileClass::from_sig(r.u32(12)?);
let color_space = ColorSpace::from_sig(r.u32(16)?);
let pcs_sig = r.u32(20)?;
let pcs = match &pcs_sig.to_be_bytes() {
b"Lab " => Pcs::Lab,
_ => Pcs::Xyz,
};
let rendering_intent = Intent::from_u32(r.u32(64)? & 0xFFFF).unwrap_or(Intent::Perceptual);
let count = r.u32(128)? as usize;
if count > 1024 {
return Err(CmsError::Invalid(format!("{count} tags")));
}
let mut tags: Vec<(u32, usize, usize)> = Vec::with_capacity(count);
for i in 0..count {
let o = 132 + i * 12;
let (s, off, len) = (r.u32(o)?, r.u32(o + 4)? as usize, r.u32(o + 8)? as usize);
tags.push((s, off, len));
}
let find = |s: &[u8; 4]| tags.iter().find(|t| t.0 == sig(s)).map(|t| (t.1, t.2));
let tag_data = |s: &[u8; 4]| -> Result<Option<&[u8]>, CmsError> {
match find(s) {
Some((off, len)) => Ok(Some(r.slice(off, len)?)),
None => Ok(None),
}
};
let xyz_tag = |s: &[u8; 4]| -> Result<Option<[f64; 3]>, CmsError> {
match tag_data(s)? {
Some(d) if d.len() >= 20 && &d[..4] == b"XYZ " => Ok(Some(Reader { b: d }.xyz(8)?)),
Some(_) => Err(CmsError::Invalid(format!("tag {} is not XYZType", String::from_utf8_lossy(s)))),
None => Ok(None),
}
};
let curve_tag = |s: &[u8; 4]| -> Result<Option<Curve>, CmsError> {
match tag_data(s)? {
Some(d) => Ok(Some(parse_curve(d)?.0)),
None => Ok(None),
}
};
let white_point = xyz_tag(b"wtpt")?.unwrap_or(math::D50);
let black_point = xyz_tag(b"bkpt").ok().flatten();
let chad = match tag_data(b"chad")? {
Some(d) if d.len() >= 44 && &d[..4] == b"sf32" => {
let rd = Reader { b: d };
let mut m = [[0.0; 3]; 3];
for (i, row) in m.iter_mut().enumerate() {
for (j, v) in row.iter_mut().enumerate() {
*v = rd.s15f16(8 + (i * 3 + j) * 4)?;
}
}
Some(m)
}
_ => None,
};
let matrix = match (xyz_tag(b"rXYZ")?, xyz_tag(b"gXYZ")?, xyz_tag(b"bXYZ")?) {
(Some(rr), Some(g), Some(b)) => Some([[rr[0], g[0], b[0]], [rr[1], g[1], b[1]], [rr[2], g[2], b[2]]]),
_ => None,
};
let trc = match (curve_tag(b"rTRC")?, curve_tag(b"gTRC")?, curve_tag(b"bTRC")?) {
(Some(a), Some(b), Some(c)) => Some([a, b, c]),
_ => None,
};
let gray_trc = curve_tag(b"kTRC")?;
let lut = |s: &[u8; 4], a2b: bool| -> Result<Option<Lut>, CmsError> {
match tag_data(s)? {
Some(d) => parse_lut(d, a2b).map(Some),
None => Ok(None),
}
};
let a2b = [lut(b"A2B0", true)?, lut(b"A2B1", true)?, lut(b"A2B2", true)?];
let b2a = [lut(b"B2A0", false)?, lut(b"B2A1", false)?, lut(b"B2A2", false)?];
let description = tag_data(b"desc")?.map(parse_text).unwrap_or_default();
let copyright = tag_data(b"cprt")?.map(parse_text).unwrap_or_default();
let p = Profile {
version,
class,
color_space,
pcs,
rendering_intent,
description,
copyright,
white_point,
black_point,
chad,
matrix,
trc,
gray_trc,
a2b,
b2a,
bytes: None,
hash: Default::default(),
};
// Validate channel counts of LUTs against the header. A DeviceLink connects two device
// spaces: its header PCS field holds the output colour space (ICC.1:2010 §7.2.7), so a
// CMYK→CMYK link has a 4→4 AToB0.
let dev = p.color_space.channels();
let conn = if p.class == ProfileClass::DeviceLink { ColorSpace::from_sig(pcs_sig).channels() } else { 3 };
for l in p.a2b.iter().flatten() {
if dev != 0 && l.inputs != dev || conn != 0 && l.outputs != conn {
return Err(CmsError::Invalid(format!("AToB tag has {}→{} channels for a {:?} profile", l.inputs, l.outputs, p.color_space)));
}
}
for l in p.b2a.iter().flatten() {
if conn != 0 && l.inputs != conn || dev != 0 && l.outputs != dev {
return Err(CmsError::Invalid(format!("BToA tag has {}→{} channels for a {:?} profile", l.inputs, l.outputs, p.color_space)));
}
}
Ok(p)
}
/// Parses a `curv` or `para` element; returns the curve and the element's byte length
/// (unpadded).
fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> {
let r = Reader { b: d };
match r.slice(0, 4)? {
b"curv" => {
let n = r.u32(8)? as usize;
let len = 12 + n * 2;
match n {
0 => Ok((Curve::Identity, len)),
1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)),
_ => {
if n > 1 << 20 {
return Err(CmsError::Invalid("curve too long".into()));
}
let s = r.slice(12, n * 2)?;
let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect();
Ok((Curve::Table(t), len))
}
}
}
b"para" => {
let kind = r.u16(8)?;
if kind > 4 {
return Err(CmsError::Unsupported(format!("parametric curve type {kind}")));
}
let n = Curve::param_count(kind);
let mut p = [0.0f64; 7];
for (i, v) in p.iter_mut().enumerate().take(n) {
*v = r.s15f16(12 + i * 4)?;
}
let len = 12 + n * 4;
let c = if kind == 0 { Curve::Gamma(p[0]) } else { Curve::Parametric { kind, p } };
Ok((c, len))
}
other => Err(CmsError::Unsupported(format!("curve type {:?}", String::from_utf8_lossy(other)))),
}
}
fn parse_curves_at(d: &[u8], off: usize, n: usize) -> Result<Vec<Curve>, CmsError> {
let mut out = Vec::with_capacity(n);
let mut o = off;
for _ in 0..n {
let sub = d.get(o..).ok_or(CmsError::Truncated)?;
let (c, len) = parse_curve(sub)?;
out.push(c);
o += (len + 3) & !3;
}
Ok(out)
}
fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
let r = Reader { b: d };
let ty = r.slice(0, 4)?;
match ty {
b"mft1" | b"mft2" => {
let wide = ty == b"mft2";
let (i, o, g) = (r.u8(8)? as usize, r.u8(9)? as usize, r.u8(10)? as usize);
if !(1..=15).contains(&i) || !(1..=15).contains(&o) || g == 0 {
return Err(CmsError::Invalid("bad lut dimensions".into()));
}
let mut m = [[0.0; 3]; 3];
for (a, row) in m.iter_mut().enumerate() {
for (b, v) in row.iter_mut().enumerate() {
*v = r.s15f16(12 + (a * 3 + b) * 4)?;
}
}
let (n_in, n_out, mut off) = if wide { (r.u16(48)? as usize, r.u16(50)? as usize, 52) } else { (256, 256, 48) };
if wide && (!(2..=4096).contains(&n_in) || !(2..=4096).contains(&n_out)) {
return Err(CmsError::Invalid("bad lut16 table size".into()));
}
let bps = if wide { 2 } else { 1 };
let read_tables = |off: &mut usize, count: usize, len: usize| -> Result<Vec<Curve>, CmsError> {
let mut v = Vec::with_capacity(count);
for _ in 0..count {
let s = r.slice(*off, len * bps)?;
let t: Vec<f32> = if wide {
s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect()
} else {
s.iter().map(|b| *b as f32 / 255.0).collect()
};
*off += len * bps;
v.push(Curve::Table(t));
}
Ok(v)
};
let in_curves = read_tables(&mut off, i, n_in)?;
let nodes = (g as u64).checked_pow(i as u32).unwrap_or(u64::MAX);
if nodes as u128 * o as u128 > MAX_CLUT_VALUES as u128 {
return Err(CmsError::Invalid("CLUT too large".into()));
}
let total = nodes as usize * o;
let s = r.slice(off, total * bps)?;
let data: Vec<f32> = if wide {
s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect()
} else {
s.iter().map(|b| *b as f32 / 255.0).collect()
};
off += total * bps;
let out_curves = read_tables(&mut off, o, n_out)?;
let mut stages = Vec::new();
// The matrix only applies when the input is XYZ (BToA with an XYZ PCS).
let ident = m.iter().enumerate().all(|(a, row)| row.iter().enumerate().all(|(b, v)| (v - if a == b { 1.0 } else { 0.0 }).abs() < 1e-6));
if !a2b && i == 3 && !ident {
// Only meaningful for an XYZ PCS; the caller decides (we cannot know the PCS here),
// so keep it: for Lab PCS writers are required to store the identity.
stages.push(Stage::matrix3(&m));
}
stages.push(Stage::Curves(in_curves));
stages.push(Stage::Clut(Clut::new(vec![g; i], o, data)));
stages.push(Stage::Curves(out_curves));
Ok(Lut { kind: if wide { LutKind::Lut16 } else { LutKind::Lut8 }, inputs: i, outputs: o, stages })
}
b"mAB " | b"mBA " => {
let (i, o) = (r.u8(8)? as usize, r.u8(9)? as usize);
if !(1..=15).contains(&i) || !(1..=15).contains(&o) {
return Err(CmsError::Invalid("bad lutAB dimensions".into()));
}
let (ob, om, omc, oc, oa) = (r.u32(12)? as usize, r.u32(16)? as usize, r.u32(20)? as usize, r.u32(24)? as usize, r.u32(28)? as usize);
let matrix = |off: usize| -> Result<Stage, CmsError> {
let mut m = vec![0.0; 9];
for (k, v) in m.iter_mut().enumerate() {
*v = r.s15f16(off + k * 4)?;
}
let offset = vec![r.s15f16(off + 36)?, r.s15f16(off + 40)?, r.s15f16(off + 44)?];
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
};
let clut = |off: usize, cin: usize, cout: usize| -> Result<Stage, CmsError> {
let mut grid = Vec::with_capacity(cin);
for k in 0..cin {
let g = r.u8(off + k)? as usize;
if g < 1 {
return Err(CmsError::Invalid("zero grid points".into()));
}
grid.push(g);
}
let prec = r.u8(off + 16)? as usize;
let nodes: u128 = grid.iter().map(|g| *g as u128).product();
if nodes * cout as u128 > MAX_CLUT_VALUES as u128 {
return Err(CmsError::Invalid("CLUT too large".into()));
}
let total = nodes as usize * cout;
let data: Vec<f32> = match prec {
1 => r.slice(off + 20, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
};
Ok(Stage::Clut(Clut::new(grid, cout, data)))
};
let mut stages = Vec::new();
if ty == b"mAB " {
// A curves → CLUT → M curves → matrix → B curves.
if oc != 0 {
if oa == 0 {
return Err(CmsError::Invalid("lutAtoB with CLUT but no A curves".into()));
}
stages.push(Stage::Curves(parse_curves_at(d, oa, i)?));
stages.push(clut(oc, i, o)?);
} else if i != o {
return Err(CmsError::Invalid("lutAtoB without CLUT changes channel count".into()));
}
if om != 0 {
stages.push(Stage::Curves(parse_curves_at(d, omc, o)?));
stages.push(matrix(om)?);
}
if ob == 0 {
return Err(CmsError::Invalid("lutAtoB without B curves".into()));
}
stages.push(Stage::Curves(parse_curves_at(d, ob, o)?));
} else {
// B curves → matrix → M curves → CLUT → A curves.
if ob == 0 {
return Err(CmsError::Invalid("lutBtoA without B curves".into()));
}
stages.push(Stage::Curves(parse_curves_at(d, ob, i)?));
if om != 0 {
stages.push(matrix(om)?);
stages.push(Stage::Curves(parse_curves_at(d, omc, i)?));
}
if oc != 0 {
if oa == 0 {
return Err(CmsError::Invalid("lutBtoA with CLUT but no A curves".into()));
}
stages.push(clut(oc, i, o)?);
stages.push(Stage::Curves(parse_curves_at(d, oa, o)?));
} else if i != o {
return Err(CmsError::Invalid("lutBtoA without CLUT changes channel count".into()));
}
}
Ok(Lut { kind: LutKind::Ab, inputs: i, outputs: o, stages })
}
other => Err(CmsError::Unsupported(format!("LUT type {:?}", String::from_utf8_lossy(other)))),
}
}
/// Text from `desc` (textDescriptionType), `mluc` (English or first record) or `text`.
fn parse_text(d: &[u8]) -> String {
let r = Reader { b: d };
let Ok(ty) = r.slice(0, 4) else { return String::new() };
let ascii = |s: &[u8]| String::from_utf8_lossy(s.split(|b| *b == 0).next().unwrap_or(&[])).trim().to_string();
match ty {
b"desc" => {
let n = r.u32(8).unwrap_or(0) as usize;
r.slice(12, n).map(ascii).unwrap_or_default()
}
b"text" => ascii(d.get(8..).unwrap_or(&[])),
b"mluc" => {
let (Ok(n), Ok(size)) = (r.u32(8), r.u32(12)) else { return String::new() };
let mut best: Option<(usize, usize)> = None;
for k in 0..(n as usize).min(256) {
let o = 16 + k * size as usize;
let (Ok(lang), Ok(len), Ok(off)) = (r.u16(o), r.u32(o + 4), r.u32(o + 8)) else { break };
if best.is_none() || lang == u16::from_be_bytes(*b"en") {
best = Some((off as usize, len as usize));
if lang == u16::from_be_bytes(*b"en") {
break;
}
}
}
let Some((off, len)) = best else { return String::new() };
let Ok(s) = r.slice(off, len) else { return String::new() };
let units: Vec<u16> = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
String::from_utf16_lossy(&units).trim_end_matches('\0').trim().to_string()
}
_ => String::new(),
}
}
+488
View File
@@ -0,0 +1,488 @@
//! Synthetic CMYK output profiles.
//!
//! Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39 …) are proprietary, and the
//! freely downloadable characterisation-based profiles come with redistribution conditions, so
//! this crate ships a CMYK profile it generates itself from a documented printer model:
//!
//! * **Forward model** (CMYK → XYZ): Yule–Nielsen modified Neugebauer (n = 2) over the 16
//! ink overprints, with Demichel coverage weights and a parabolic tone value increase (dot
//! gain) per ink. The eight K-free primaries use the published ISO 12647-2 coated-paper Lab
//! aim values (paper, C, M, Y, the three two-colour overprints) and an estimated three-colour
//! black; overprints with black are derived multiplicatively with a first-surface reflection
//! floor.
//! * **AToB** (all intents): 11⁴ `lut16` table of the forward model, relative colorimetric
//! (paper → L* 100).
//! * **BToA1** (colorimetric): for every Lab node, black generation (GCR) from the gray
//! component of the K-free solution, then a bounded least-squares solve for C, M, Y with the
//! total ink limit; out-of-gamut colours land on the closest reachable colour (clipping).
//! * **BToA0/BToA2** (perceptual/saturation): the target is first compressed into the gamut —
//! L* is scaled onto the device black, chroma is softly compressed against a gamut boundary
//! descriptor sampled from the forward model — then solved as above.
//!
//! The model is plausible for coated offset, not a measured characterisation: use a real
//! profile from your print provider for production print.
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};
pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
/// Parameters of the synthetic profile.
#[derive(Clone, Debug)]
pub struct CmykParams {
pub description: 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.
pub gcr_start: f64,
/// Maximum black.
pub k_max: f64,
/// Exponent of the black generation curve above `gcr_start`.
pub gcr_gamma: f64,
/// Tone value increase at 50 % for C, M, Y, K.
pub tvi: [f64; 4],
/// Grid points of the AToB (4D) and BToA (3D) tables.
pub grid_a2b: usize,
pub grid_b2a: usize,
}
impl Default for CmykParams {
fn default() -> Self {
CmykParams {
description: DESCRIPTION.into(),
tac: 3.0,
gcr_start: 0.2,
k_max: 0.95,
gcr_gamma: 1.1,
tvi: [0.14, 0.14, 0.14, 0.17],
grid_a2b: 11,
grid_b2a: 21,
}
}
}
/// The printer model.
#[derive(Clone, Debug)]
pub struct CmykModel {
/// Absolute XYZ (D50) of the 16 overprints, indexed by ink bit mask (C=1, M=2, Y=4, K=8),
/// stored as `X^(1/n)` for the Yule–Nielsen sum.
prim_n: [[f64; 3]; 16],
/// Absolute XYZ of the paper.
pub paper: [f64; 3],
tvi: [f64; 4],
}
const YN: f64 = 2.0;
impl CmykModel {
pub fn coated(tvi: [f64; 4]) -> Self {
let lab = |l: f64, a: f64, b: f64| math::lab_to_xyz([l, a, b], math::D50);
// ISO 12647-2 (coated, paper type 1/2) Lab aims, D50/2°; CMY overprint estimated.
let w = lab(95.0, 0.0, -2.0);
let base: [[f64; 3]; 8] = [
w, // paper
lab(55.0, -37.0, -50.0), // C
lab(48.0, 74.0, -3.0), // M
lab(24.0, 22.0, -46.0), // C+M (blue)
lab(89.0, -5.0, 93.0), // Y
lab(50.0, -65.0, 27.0), // C+Y (green)
lab(47.0, 68.0, 48.0), // M+Y (red)
lab(23.0, 0.0, 0.0), // C+M+Y
];
let k = lab(16.0, 0.0, 0.0);
// Overprints with black: multiplicative in reflectance above a first-surface floor.
let floor = 0.006;
let mut prim = [[0.0; 3]; 16];
for m in 0..16 {
let p = base[m & 7];
prim[m] = if m & 8 == 0 {
p
} else {
std::array::from_fn(|i| {
let s = floor * w[i];
s + (p[i] - s) * (k[i] - s) / (w[i] - s)
})
};
}
let prim_n = prim.map(|p| p.map(|v| v.max(0.0).powf(1.0 / YN)));
CmykModel { prim_n, paper: w, tvi }
}
/// Absolute XYZ of an ink combination (each coverage in `[0, 1]`).
pub fn xyz(&self, cmyk: [f64; 4]) -> [f64; 3] {
let a: [f64; 4] = std::array::from_fn(|i| {
let v = cmyk[i].clamp(0.0, 1.0);
(v + 4.0 * self.tvi[i] * v * (1.0 - v)).clamp(0.0, 1.0)
});
let mut s = [0.0; 3];
for m in 0..16 {
let mut w = 1.0;
for (i, ai) in a.iter().enumerate() {
w *= if m & (1 << i) != 0 { *ai } else { 1.0 - ai };
}
if w == 0.0 {
continue;
}
for (acc, p) in s.iter_mut().zip(&self.prim_n[m]) {
*acc += w * p;
}
}
s.map(|v| v.powf(YN))
}
/// ICC-relative Lab (paper = L* 100).
pub fn lab(&self, cmyk: [f64; 4]) -> [f64; 3] {
let x = self.xyz(cmyk);
let rel = [x[0] * math::D50[0] / self.paper[0], x[1] * math::D50[1] / self.paper[1], x[2] * math::D50[2] / self.paper[2]];
math::xyz_to_lab(rel, math::D50)
}
}
/// 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) {
let project = |mut x: [f64; 3]| {
for _ in 0..4 {
for v in &mut x {
*v = v.clamp(0.0, 1.0);
}
let s: f64 = x.iter().sum();
if s <= limit + 1e-12 {
break;
}
let active = x.iter().filter(|v| **v > 0.0).count().max(1) as f64;
let d = (s - limit) / active;
for v in &mut x {
if *v > 0.0 {
*v -= d;
}
}
}
x
};
let err = |x: [f64; 3]| {
let l = model.lab([x[0], x[1], x[2], k]);
[l[0] - target[0], l[1] - target[1], l[2] - target[2]]
};
let norm2 = |e: [f64; 3]| e[0] * e[0] + e[1] * e[1] + e[2] * e[2];
let mut best = ([0.0; 3], f64::INFINITY);
for seed in seeds {
let mut x = project(*seed);
let mut e = err(x);
let mut f = norm2(e);
let mut lambda = 1e-3;
for _ in 0..60 {
// Jacobian (3×3) by forward differences.
let h = 1e-5;
let mut j = [[0.0; 3]; 3];
for c in 0..3 {
let mut xp = x;
xp[c] += h;
let ep = err(xp);
for r in 0..3 {
j[r][c] = (ep[r] - e[r]) / h;
}
}
// (JᵀJ + λ diag) δ = −Jᵀe
let mut a = [[0.0; 3]; 3];
let mut g = [0.0; 3];
for r in 0..3 {
for c in 0..3 {
a[r][c] = (0..3).map(|k| j[k][r] * j[k][c]).sum();
}
g[r] = -(0..3).map(|k| j[k][r] * e[k]).sum::<f64>();
}
let mut improved = false;
for _ in 0..8 {
let mut al = a;
for (d, row) in al.iter_mut().enumerate() {
row[d] += lambda * (1.0 + a[d][d]);
}
let Some(inv) = math::invert(&al) else {
lambda *= 10.0;
continue;
};
let step = math::apply(&inv, g);
let xn = project([x[0] + step[0], x[1] + step[1], x[2] + step[2]]);
let en = err(xn);
let fnew = norm2(en);
if fnew < f {
let moved = (0..3).map(|i| (xn[i] - x[i]).abs()).fold(0.0, f64::max);
x = xn;
e = en;
f = fnew;
lambda = (lambda * 0.3).max(1e-9);
improved = moved > 1e-9;
break;
}
lambda *= 10.0;
}
if !improved || f < 1e-10 {
break;
}
}
if f < best.1 {
best = (x, f);
}
if best.1 < 1e-6 {
break;
}
}
(best.0, best.1.sqrt())
}
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) {
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);
let g = cmy0[0].min(cmy0[1]).min(cmy0[2]);
let k = if g <= p.gcr_start { 0.0 } else { p.k_max * ((g - p.gcr_start) / (1.0 - p.gcr_start)).powf(p.gcr_gamma) };
let mut seeds2 = vec![[(cmy0[0] - k).max(0.0), (cmy0[1] - k).max(0.0), (cmy0[2] - k).max(0.0)], cmy0];
seeds2.extend_from_slice(&seeds);
let (cmy, de) = solve_cmy(model, target, k, (p.tac - k).max(0.0), &seeds2);
([cmy[0], cmy[1], cmy[2], k], de)
}
/// Gamut boundary descriptor: maximum chroma per (L*, hue) bin, sampled from the model.
struct Gbd {
l_bins: usize,
h_bins: usize,
cmax: Vec<f64>,
black_l: f64,
}
impl Gbd {
fn new(model: &CmykModel, p: &CmykParams) -> Self {
let (l_bins, h_bins) = (41usize, 72usize);
let mut cmax = vec![0.0f64; l_bins * h_bins];
let n = 21;
let mut black_l = 100.0f64;
for ci in 0..n {
for mi in 0..n {
for yi in 0..n {
for ki in 0..11 {
let v = [ci as f64 / (n - 1) as f64, mi as f64 / (n - 1) as f64, yi as f64 / (n - 1) as f64, ki as f64 / 10.0];
if v.iter().sum::<f64>() > p.tac + 1e-9 {
continue;
}
let lab = model.lab(v);
black_l = black_l.min(lab[0]);
let c = lab[1].hypot(lab[2]);
let h = lab[2].atan2(lab[1]).rem_euclid(std::f64::consts::TAU);
let li = ((lab[0] / 100.0) * (l_bins - 1) as f64).round().clamp(0.0, (l_bins - 1) as f64) as usize;
let hi = ((h / std::f64::consts::TAU) * h_bins as f64).round() as usize % h_bins;
let e = &mut cmax[li * h_bins + hi];
*e = e.max(c);
}
}
}
}
// Fill holes and smooth: max over a 3×3 neighbourhood, then a box blur.
let get = |v: &Vec<f64>, l: isize, h: isize| {
let l = l.clamp(0, l_bins as isize - 1) as usize;
let h = h.rem_euclid(h_bins as isize) as usize;
v[l * h_bins + h]
};
let mut filled = cmax.clone();
for l in 0..l_bins as isize {
for h in 0..h_bins as isize {
let mut m = 0.0f64;
for dl in -1..=1 {
for dh in -1..=1 {
m = m.max(get(&cmax, l + dl, h + dh));
}
}
filled[l as usize * h_bins + h as usize] = m;
}
}
let mut smooth = filled.clone();
for l in 0..l_bins as isize {
for h in 0..h_bins as isize {
let mut s = 0.0;
for dl in -1..=1 {
for dh in -1..=1 {
s += get(&filled, l + dl, h + dh);
}
}
smooth[l as usize * h_bins + h as usize] = s / 9.0;
}
}
Gbd { l_bins, h_bins, cmax: smooth, black_l }
}
fn max_chroma(&self, l: f64, h: f64) -> f64 {
let lp = (l / 100.0).clamp(0.0, 1.0) * (self.l_bins - 1) as f64;
let hp = (h.rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU) * self.h_bins as f64;
let (l0, h0) = (lp.floor() as usize, hp.floor() as usize);
let (fl, fh) = (lp - l0 as f64, hp - h0 as f64);
let l1 = (l0 + 1).min(self.l_bins - 1);
let (h0m, h1m) = (h0 % self.h_bins, (h0 + 1) % self.h_bins);
let v = |l: usize, h: usize| self.cmax[l * self.h_bins + h];
let a = v(l0, h0m) + (v(l0, h1m) - v(l0, h0m)) * fh;
let b = v(l1, h0m) + (v(l1, h1m) - v(l1, h0m)) * fh;
a + (b - a) * fl
}
/// Perceptual gamut compression of a relative Lab colour.
fn compress(&self, lab: [f64; 3]) -> [f64; 3] {
let l = self.black_l + lab[0].clamp(0.0, 100.0) * (100.0 - self.black_l) / 100.0;
let c = lab[1].hypot(lab[2]);
if c < 1e-9 {
return [l, 0.0, 0.0];
}
let h = lab[2].atan2(lab[1]);
let cm = self.max_chroma(l, h).max(1e-3);
let knee = 0.8 * cm;
let c2 = if c <= knee { c } else { knee + (cm - knee) * ((c - knee) / (cm - knee)).tanh() };
[l, c2 * h.cos(), c2 * h.sin()]
}
}
fn lab_node_legacy(i: usize, n: usize) -> f64 {
i as f64 / (n - 1) as f64
}
/// Decodes a legacy (lut16) Lab node coordinate triple.
fn decode_v2(v: [f64; 3]) -> [f64; 3] {
[v[0] * 100.0 * 65535.0 / 65280.0, v[1] * 65535.0 / 256.0 - 128.0, v[2] * 65535.0 / 256.0 - 128.0]
}
fn encode_v2(lab: [f64; 3]) -> [f32; 3] {
[
(lab[0] / (100.0 * 65535.0 / 65280.0)).clamp(0.0, 1.0) as f32,
((lab[1] + 128.0) * 256.0 / 65535.0).clamp(0.0, 1.0) as f32,
((lab[2] + 128.0) * 256.0 / 65535.0).clamp(0.0, 1.0) as f32,
]
}
#[cfg(not(target_arch = "wasm32"))]
fn par_map<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
use rayon::prelude::*;
(0..n).into_par_iter().map(f).collect()
}
#[cfg(target_arch = "wasm32")]
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;
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.
let (li, ai) = (row / n, row % n);
let mut warm = None;
let mut out = Vec::with_capacity(n * 4);
for bi in 0..n {
let lab = decode_v2([lab_node_legacy(li, n), lab_node_legacy(ai, n), lab_node_legacy(bi, n)]);
let target = match gbd {
Some(g) => g.compress(lab),
None => lab,
};
let (cmyk, _) = separate(model, p, target, warm);
warm = Some(cmyk);
out.extend(cmyk.map(|v| v as f32));
}
out
});
let data: Vec<f32> = vals.into_iter().flatten().collect();
debug_assert_eq!(data.len(), nodes * 4);
Clut::new(vec![n; 3], 4, data)
}
fn lut16(clut: Clut) -> Lut {
let (i, o) = (clut.inputs, clut.outputs);
Lut {
kind: LutKind::Lut16,
inputs: i,
outputs: o,
stages: vec![Stage::Curves(vec![Curve::Table(vec![0.0, 1.0]); i]), Stage::Clut(clut), Stage::Curves(vec![Curve::Table(vec![0.0, 1.0]); o])],
}
}
/// 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 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));
let a2b = lut16(a2b);
let (b0, b1) = (lut16(perceptual), lut16(colorimetric));
Profile {
version: (4, 0x30),
class: ProfileClass::Output,
color_space: ColorSpace::Cmyk,
pcs: Pcs::Lab,
rendering_intent: Intent::Perceptual,
description: p.description.clone(),
copyright: crate::builtin::COPYRIGHT.into(),
white_point: model.paper.map(math::s15f16_round),
black_point: None,
chad: None,
matrix: None,
trc: None,
gray_trc: None,
a2b: [Some(a2b.clone()), Some(a2b.clone()), Some(a2b)],
b2a: [Some(b0.clone()), Some(b1), Some(b0)],
bytes: None,
hash: Default::default(),
}
.with_encoded_bytes()
}
/// The default coated profile (what `profiles/photocraft-coated-cmyk.icc` contains).
pub fn coated_cmyk() -> Profile {
cmyk_profile(&CmykParams::default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_anchors() {
let m = CmykModel::coated(CmykParams::default().tvi);
let w = m.lab([0.0; 4]);
assert!((w[0] - 100.0).abs() < 1e-9 && w[1].abs() < 1e-9 && w[2].abs() < 1e-9, "{w:?}");
let k = m.lab([0.0, 0.0, 0.0, 1.0]);
assert!(k[0] > 12.0 && k[0] < 22.0, "{k:?}");
let rich = m.lab([0.75, 0.65, 0.6, 0.9]);
assert!(rich[0] < k[0], "{rich:?}");
// Monotonic in each ink.
for ink in 0..4 {
let mut prev = 101.0;
for i in 0..=20 {
let mut v = [0.2, 0.2, 0.2, 0.2];
v[ink] = i as f64 / 20.0;
let l = m.lab(v)[0];
assert!(l < prev + 1e-9, "ink {ink} step {i}: {l} >= {prev}");
prev = l;
}
}
}
#[test]
fn separation_hits_in_gamut_targets() {
let m = CmykModel::coated(CmykParams::default().tvi);
let p = CmykParams::default();
let reachable = [[0.3, 0.4, 0.1, 0.1], [0.6, 0.1, 0.5, 0.0], [0.05, 0.1, 0.7, 0.0], [0.5, 0.45, 0.45, 0.4]].map(|v| m.lab(v));
for target in [[50.0, 0.0, 0.0]].into_iter().chain(reachable) {
let (cmyk, de) = separate(&m, &p, target, None);
assert!(de < 1.5, "{target:?} -> {cmyk:?} ΔE {de}");
assert!(cmyk.iter().sum::<f64>() <= p.tac + 1e-6);
}
}
}
+736
View File
@@ -0,0 +1,736 @@
//! Colour transforms between profiles through the PCS, with rendering intents, black point
//! compensation, soft-proofing chains, and fast lookup-table evaluation of pixel buffers.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use crate::clut::{Clut, MAX_CHANNELS};
use crate::curve::Curve;
use crate::math;
use crate::pipeline::{Pipeline, Stage};
use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass};
use crate::{CmsError, Intent};
/// Sample storage of a byte buffer for [`Transform::convert_bytes_many`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SampleKind {
U8,
U16,
F32,
}
/// Options for building a [`Transform`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct TransformOptions {
pub intent: Intent,
/// Adobe-style black point compensation (ignored for absolute colorimetric).
pub bpc: bool,
/// `true`: float buffers go through the exact pipeline (default). `false`: float buffers use
/// the same lookup tables as integer buffers (faster, for previews).
pub precise_float: bool,
}
impl Default for TransformOptions {
fn default() -> Self {
Self { intent: Intent::RelativeColorimetric, bpc: true, precise_float: true }
}
}
/// Grid sizes of the device-link tables built for integer (and fast float) evaluation.
pub const GRID_3D: usize = 33;
pub const GRID_4D: usize = 17;
const TABLE_1D: usize = 4096;
/// Resolution of output shaper tables (indexed by `x^(1/4)`).
const OUT_TABLE: usize = 4096;
/// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ.
const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287];
/// Accelerated representation of a transform.
#[derive(Debug)]
enum Core {
/// Input curves (sampled per input channel over `[0, 1]`, 4096+1 entries; 256 exact
/// entries for 8-bit input) followed by an optional matrix.
Shaper { curves8: Vec<[f32; 256]>, curves: Vec<Vec<f32>>, matrix: Option<(usize, usize, Vec<f32>, Vec<f32>)> },
/// Device link table over the input space.
Grid(Clut),
/// No acceleration possible: use the exact pipeline.
Exact,
}
/// Output inverse-curve tables indexed by `x^(1/4)` (accurate near black even for pure gamma
/// curves, whose slope is infinite at zero).
#[derive(Debug)]
struct Post {
tables: Vec<Vec<f32>>,
}
impl Post {
#[inline]
fn eval(&self, c: usize, x: f32) -> f32 {
let t = &self.tables[c];
let u = x.clamp(0.0, 1.0).sqrt().sqrt() * (OUT_TABLE - 1) as f32;
let i = (u as usize).min(OUT_TABLE - 2);
let f = u - i as f32;
t[i] + (t[i + 1] - t[i]) * f
}
}
/// A colour transform from one profile's device space to another's.
///
/// Build once (it precomputes lookup tables) and apply to any number of buffers from any
/// thread. Buffers are interleaved; colour channels come first in each pixel and any further
/// channels (alpha, spot) are copied or left alone.
#[derive(Debug)]
pub struct Transform {
inputs: usize,
outputs: usize,
opts: TransformOptions,
pipeline: Pipeline,
core: Core,
post: Option<Post>,
/// Pre-located grid coordinates per 8-bit code: (offset contribution, fraction) per input.
grid8: Vec<[(u32, f32); 256]>,
}
impl Transform {
/// Transform from `src` to `dst` with `intent`, optionally with black point compensation.
pub fn new(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Result<Transform, CmsError> {
Self::with_options(src, dst, TransformOptions { intent, bpc, ..Default::default() })
}
pub fn with_options(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result<Transform, CmsError> {
let stages = link_stages(src, dst, opts.intent, opts.bpc)?;
let mut t = Self::from_pipeline(Pipeline::new(src.channels(), stages), opts);
if opts.intent != Intent::AbsoluteColorimetric {
t.fix_white(src.color_space, dst.color_space);
}
Ok(t)
}
/// Soft-proof transform `src → proof → dst` (Photoshop View › Proof Setup): `intent`/`bpc`
/// apply to the document → proof step; the proof → display step is relative colorimetric
/// with BPC, or absolute colorimetric without BPC when `simulate_paper` is set (paper
/// colour and black ink simulated).
pub fn proof(src: &Profile, proof: &Profile, dst: &Profile, intent: Intent, bpc: bool, simulate_paper: bool) -> Result<Transform, CmsError> {
let mut stages = link_stages(src, proof, intent, bpc)?;
stages.push(Stage::Clamp01);
let (i2, b2) = if simulate_paper { (Intent::AbsoluteColorimetric, false) } else { (Intent::RelativeColorimetric, true) };
stages.extend(link_stages(proof, dst, i2, b2)?);
Ok(Self::from_pipeline(Pipeline::new(src.channels(), stages), TransformOptions { intent, bpc, precise_float: true }))
}
/// Builds a transform from an arbitrary pipeline over normalised values.
pub fn from_pipeline(mut pipeline: Pipeline, opts: TransformOptions) -> Transform {
pipeline.optimize();
let (inputs, outputs) = (pipeline.inputs, pipeline.outputs);
// Split trailing inverse curves into accurate output tables.
let mut head = pipeline.stages.clone();
let mut post = None;
if let Some(Stage::InvCurves(cs)) = head.last()
&& cs.len() == outputs
{
let tables = cs
.iter()
.map(|c| {
(0..OUT_TABLE)
.map(|i| {
let u = i as f64 / (OUT_TABLE - 1) as f64;
c.eval_inverse64(u * u * u * u).clamp(0.0, 1.0) as f32
})
.collect()
})
.collect();
post = Some(Post { tables });
head.pop();
if matches!(head.last(), Some(Stage::Clamp01)) {
head.pop();
}
}
let head_pipe = Pipeline::new(inputs, head.clone());
let mid = head_pipe.outputs;
let core = match head.as_slice() {
[] => Core::Shaper { curves8: Vec::new(), curves: Vec::new(), matrix: None },
[Stage::Curves(cs)] if cs.len() == inputs => shaper(cs, None),
[Stage::Matrix { rows, cols, m, offset }] => shaper(&[], Some((*rows, *cols, m, offset))),
[Stage::Curves(cs), Stage::Matrix { rows, cols, m, offset }] if cs.len() == inputs => shaper(cs, Some((*rows, *cols, m, offset))),
// Purely analytic heads (Lab/XYZ maths, matrices, curves) are evaluated exactly:
// a grid would amplify interpolation error through steep output curves.
_ if !head.iter().any(|s| matches!(s, Stage::Clut(_))) => Core::Exact,
_ => match inputs {
1 => Core::Grid(Clut::sample(vec![TABLE_1D], mid, |i, o| head_pipe.eval(i, o))),
3 => Core::Grid(Clut::sample(vec![GRID_3D; 3], mid, |i, o| head_pipe.eval(i, o))),
4 => Core::Grid(Clut::sample(vec![GRID_4D; 4], mid, |i, o| head_pipe.eval(i, o))),
2 => Core::Grid(Clut::sample(vec![GRID_3D; 2], mid, |i, o| head_pipe.eval(i, o))),
_ => Core::Exact,
},
};
let grid8 = match &core {
Core::Grid(c) if c.inputs >= 2 => (0..c.inputs)
.map(|d| {
let n = c.grid[d];
let stride = c.data.len() / c.outputs / c.grid[..=d].iter().product::<usize>() * c.outputs;
std::array::from_fn(|v| {
let p = v as f32 / 255.0 * (n - 1) as f32;
let i = (p as usize).min(n - 2);
((i * stride) as u32, p - i as f32)
})
})
.collect(),
_ => Vec::new(),
};
Transform { inputs, outputs, opts, pipeline, core, post, grid8 }
}
/// White preservation: the device-link node at the source's device white gets exactly the
/// destination's device white, so paper/white survive table quantization (e.g. the PCS
/// white is not a node of a legacy-encoded Lab grid).
fn fix_white(&mut self, src: ColorSpace, dst: ColorSpace) {
let (Some(sw), Some(mut dw)) = (device_white(src), device_white(dst)) else { return };
if self.post.is_some() {
// Output curves map 1 → 1 for additive spaces; the grid holds pre-curve values.
if !matches!(dst, ColorSpace::Rgb | ColorSpace::Gray) {
return;
}
dw.iter_mut().for_each(|v| *v = 1.0);
}
let Core::Grid(c) = &mut self.core else { return };
if c.inputs != sw.len() || c.outputs != dw.len() {
return;
}
let mut off = 0usize;
let mut stride = c.outputs;
for d in (0..c.inputs).rev() {
let n = c.grid[d];
let pos = sw[d] * (n - 1) as f32;
if (pos - pos.round()).abs() > 1e-6 {
return;
}
off += pos.round() as usize * stride;
stride *= n;
}
c.data[off..off + c.outputs].copy_from_slice(&dw);
}
pub fn inputs(&self) -> usize {
self.inputs
}
pub fn outputs(&self) -> usize {
self.outputs
}
pub fn options(&self) -> TransformOptions {
self.opts
}
/// The exact evaluation pipeline.
pub fn pipeline(&self) -> &Pipeline {
&self.pipeline
}
/// Exact evaluation of one colour (`inputs` → `outputs` normalised values).
#[inline]
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
self.pipeline.eval(input, out);
}
/// Lookup-table evaluation of one colour (what integer buffers use).
#[inline]
pub fn eval_fast(&self, input: &[f32], out: &mut [f32]) {
let mut mid = [0.0f32; 16];
match &self.core {
Core::Exact => return self.pipeline.eval(input, out),
Core::Grid(c) => {
let mut inp = [0.0f32; 16];
for (d, s) in inp.iter_mut().zip(&input[..self.inputs]) {
*d = s.clamp(0.0, 1.0);
}
c.eval(&inp[..self.inputs], &mut mid[..c.outputs]);
}
Core::Shaper { curves, matrix, .. } => {
let mut v = [0.0f32; 16];
for k in 0..self.inputs {
v[k] = if curves.is_empty() { input[k] } else { table(&curves[k], input[k]) };
}
apply_matrix(matrix, &v, &mut mid, self.inputs);
}
}
self.finish(&mid, out);
}
#[inline]
fn finish(&self, mid: &[f32], out: &mut [f32]) {
match &self.post {
Some(p) => {
for k in 0..self.outputs {
out[k] = p.eval(k, mid[k]);
}
}
None => out[..self.outputs].copy_from_slice(&mid[..self.outputs]),
}
}
/// Converts float pixels. `src`/`dst` hold whole pixels of `src_stride`/`dst_stride`
/// values; colour channels come first. Trailing channels are copied when `copy_extra`.
pub fn convert_f32(&self, src: &[f32], src_stride: usize, dst: &mut [f32], dst_stride: usize, copy_extra: bool) {
self.check(src_stride, dst_stride);
let extra = extra_channels(self, src_stride, dst_stride, copy_extra);
par_rows(src, src_stride, dst, dst_stride, |s, d| self.run_f32(s, src_stride, d, dst_stride, extra));
}
fn run_f32(&self, s: &[f32], ss: usize, d: &mut [f32], ds: usize, extra: usize) {
let precise = self.opts.precise_float || matches!(self.core, Core::Exact);
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
if precise {
self.pipeline.eval(sp, dp);
} else {
self.eval_fast(sp, dp);
}
for e in 0..extra {
dp[self.outputs + e] = sp[self.inputs + e];
}
}
}
/// In-place float conversion; `stride ≥ max(inputs, outputs)`.
pub fn apply(&self, buf: &mut [f32], stride: usize) {
assert!(stride >= self.inputs.max(self.outputs), "stride too small");
let precise = self.opts.precise_float || matches!(self.core, Core::Exact);
let body = |chunk: &mut [f32]| {
let mut out = [0.0f32; 16];
for p in chunk.chunks_exact_mut(stride) {
if precise {
self.pipeline.eval(p, &mut out);
} else {
self.eval_fast(p, &mut out);
}
p[..self.outputs].copy_from_slice(&out[..self.outputs]);
}
};
par_inplace(buf, stride, body);
}
/// Converts 8-bit pixels (see [`Transform::convert_f32`] for the layout).
pub fn convert_u8(&self, src: &[u8], src_stride: usize, dst: &mut [u8], dst_stride: usize, copy_extra: bool) {
self.check(src_stride, dst_stride);
let extra = extra_channels(self, src_stride, dst_stride, copy_extra);
par_rows(src, src_stride, dst, dst_stride, |s, d| self.run_u8(s, src_stride, d, dst_stride, extra));
}
fn run_u8(&self, s: &[u8], ss: usize, d: &mut [u8], ds: usize, extra: usize) {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
let mut mid = [0.0f32; 16];
let mut out = [0.0f32; 16];
match &self.core {
Core::Grid(c) if c.inputs == 3 => {
let (g0, g1, g2) = (&self.grid8[0], &self.grid8[1], &self.grid8[2]);
let s1 = c.outputs * c.grid[2];
let s0 = s1 * c.grid[1];
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
let (a, b, cc) = (g0[sp[0] as usize], g1[sp[1] as usize], g2[sp[2] as usize]);
c.tetra_pub((a.0 + b.0 + cc.0) as usize, [s0, s1, c.outputs], [a.1, b.1, cc.1], &mut mid);
self.finish(&mid, &mut out);
for k in 0..self.outputs {
dp[k] = q(out[k]);
}
for e in 0..extra {
dp[self.outputs + e] = sp[self.inputs + e];
}
}
}
Core::Grid(c) if c.inputs == 4 => {
let g = &self.grid8;
let s3 = c.outputs;
let s2 = s3 * c.grid[3];
let s1 = s2 * c.grid[2];
let s0 = s1 * c.grid[1];
let mut hi = [0.0f32; 16];
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
let (a, b, cc, k4) = (g[0][sp[0] as usize], g[1][sp[1] as usize], g[2][sp[2] as usize], g[3][sp[3] as usize]);
let base = (a.0 + b.0 + cc.0 + k4.0) as usize;
c.tetra_pub(base, [s1, s2, s3], [b.1, cc.1, k4.1], &mut mid);
if a.1 > 0.0 {
c.tetra_pub(base + s0, [s1, s2, s3], [b.1, cc.1, k4.1], &mut hi);
for k in 0..c.outputs {
mid[k] += (hi[k] - mid[k]) * a.1;
}
}
self.finish(&mid, &mut out);
for k in 0..self.outputs {
dp[k] = q(out[k]);
}
for e in 0..extra {
dp[self.outputs + e] = sp[self.inputs + e];
}
}
}
Core::Shaper { curves8, matrix, .. } => {
let mut v = [0.0f32; 16];
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
for k in 0..self.inputs {
v[k] = if curves8.is_empty() { sp[k] as f32 / 255.0 } else { curves8[k][sp[k] as usize] };
}
apply_matrix(matrix, &v, &mut mid, self.inputs);
self.finish(&mid, &mut out);
for k in 0..self.outputs {
dp[k] = q(out[k]);
}
for e in 0..extra {
dp[self.outputs + e] = sp[self.inputs + e];
}
}
}
_ => {
let mut inp = [0.0f32; 16];
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
for k in 0..self.inputs {
inp[k] = sp[k] as f32 / 255.0;
}
self.eval_fast(&inp, &mut out);
for k in 0..self.outputs {
dp[k] = q(out[k]);
}
for e in 0..extra {
dp[self.outputs + e] = sp[self.inputs + e];
}
}
}
}
}
/// Converts 16-bit pixels (see [`Transform::convert_f32`] for the layout).
pub fn convert_u16(&self, src: &[u16], src_stride: usize, dst: &mut [u16], dst_stride: usize, copy_extra: bool) {
self.check(src_stride, dst_stride);
let extra = extra_channels(self, src_stride, dst_stride, copy_extra);
par_rows(src, src_stride, dst, dst_stride, |s, d| self.run_u16(s, src_stride, d, dst_stride, extra));
}
fn run_u16(&self, s: &[u16], ss: usize, d: &mut [u16], ds: usize, extra: usize) {
let mut inp = [0.0f32; 16];
let mut out = [0.0f32; 16];
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
for k in 0..self.inputs {
inp[k] = sp[k] as f32 / 65535.0;
}
self.eval_fast(&inp, &mut out);
for k in 0..self.outputs {
dp[k] = (out[k].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
}
for e in 0..extra {
dp[self.outputs + e] = sp[self.inputs + e];
}
}
}
/// Converts many independent buffers of native-endian samples in one parallel pass (e.g.
/// all tiles of a surface): far cheaper than one parallel call per small buffer. Each job
/// is `(src bytes, dst bytes)` holding whole pixels of `src_stride`/`dst_stride` samples.
pub fn convert_bytes_many(&self, sample: SampleKind, jobs: Vec<(&[u8], &mut [u8])>, src_stride: usize, dst_stride: usize, copy_extra: bool) {
self.check(src_stride, dst_stride);
let extra = extra_channels(self, src_stride, dst_stride, copy_extra);
let run = |(s, d): (&[u8], &mut [u8])| match sample {
SampleKind::U8 => self.run_u8(s, src_stride, d, dst_stride, extra),
SampleKind::U16 => {
let a: Vec<u16> = s.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes([b[0], b[1]])).collect();
let mut o: Vec<u16> = d.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes([b[0], b[1]])).collect();
self.run_u16(&a, src_stride, &mut o, dst_stride, extra);
for (b, v) in d.as_chunks_mut::<2>().0.iter_mut().zip(o) {
b.copy_from_slice(&v.to_ne_bytes());
}
}
SampleKind::F32 => {
let a: Vec<f32> = s.as_chunks::<4>().0.iter().map(|b| f32::from_ne_bytes([b[0], b[1], b[2], b[3]])).collect();
let mut o: Vec<f32> = d.as_chunks::<4>().0.iter().map(|b| f32::from_ne_bytes([b[0], b[1], b[2], b[3]])).collect();
self.run_f32(&a, src_stride, &mut o, dst_stride, extra);
for (b, v) in d.as_chunks_mut::<4>().0.iter_mut().zip(o) {
b.copy_from_slice(&v.to_ne_bytes());
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
jobs.into_par_iter().for_each(run);
}
#[cfg(target_arch = "wasm32")]
jobs.into_iter().for_each(run);
}
fn check(&self, ss: usize, ds: usize) {
assert!(ss >= self.inputs && ds >= self.outputs, "pixel stride smaller than the channel count");
}
}
fn device_white(cs: ColorSpace) -> Option<Vec<f32>> {
Some(match cs {
ColorSpace::Rgb => vec![1.0; 3],
ColorSpace::Gray => vec![1.0],
ColorSpace::Cmyk => vec![0.0; 4],
ColorSpace::Cmy => vec![0.0; 3],
ColorSpace::Lab => vec![1.0, 0.5, 0.5],
_ => return None,
})
}
fn extra_channels(t: &Transform, ss: usize, ds: usize, copy: bool) -> usize {
if copy { (ss - t.inputs).min(ds - t.outputs) } else { 0 }
}
fn shaper(cs: &[Curve], matrix: Option<(usize, usize, &Vec<f64>, &Vec<f64>)>) -> Core {
let curves8 = cs.iter().map(|c| std::array::from_fn(|i| c.eval64(i as f64 / 255.0) as f32)).collect();
let curves = cs.iter().map(|c| (0..=TABLE_1D).map(|i| c.eval64(i as f64 / TABLE_1D as f64) as f32).collect()).collect();
let matrix = matrix.map(|(r, c, m, o)| (r, c, m.iter().map(|v| *v as f32).collect(), o.iter().map(|v| *v as f32).collect()));
Core::Shaper { curves8, curves, matrix }
}
#[inline]
fn table(t: &[f32], x: f32) -> f32 {
// Sampled over [0, 1] with TABLE_1D segments; values outside extrapolate linearly from
// the end segments so HDR floats stay usable in the fast path.
let n = t.len() - 1;
let p = x * n as f32;
let i = (p.floor().max(0.0) as usize).min(n - 1);
let f = p - i as f32;
t[i] + (t[i + 1] - t[i]) * f
}
#[inline]
fn apply_matrix(m: &Option<(usize, usize, Vec<f32>, Vec<f32>)>, v: &[f32; 16], out: &mut [f32; 16], n: usize) {
match m {
None => out[..n].copy_from_slice(&v[..n]),
Some((rows, cols, m, off)) => {
for r in 0..*rows {
let mut s = off[r];
for c in 0..*cols {
s += m[r * cols + c] * v[c];
}
out[r] = s;
}
}
}
}
// ------------------------------------------------------------ parallel helpers
#[cfg(not(target_arch = "wasm32"))]
const PAR_MIN_PIXELS: usize = 16 * 1024;
#[cfg(not(target_arch = "wasm32"))]
const PAR_CHUNK_PIXELS: usize = 8 * 1024;
#[cfg(not(target_arch = "wasm32"))]
fn par_rows<S: Sync, D: Send>(src: &[S], ss: usize, dst: &mut [D], ds: usize, f: impl Fn(&[S], &mut [D]) + Sync) {
use rayon::prelude::*;
let n = (src.len() / ss).min(dst.len() / ds);
let (src, dst) = (&src[..n * ss], &mut dst[..n * ds]);
if n < PAR_MIN_PIXELS {
return f(src, dst);
}
src.par_chunks(PAR_CHUNK_PIXELS * ss).zip(dst.par_chunks_mut(PAR_CHUNK_PIXELS * ds)).for_each(|(s, d)| f(s, d));
}
#[cfg(target_arch = "wasm32")]
fn par_rows<S: Sync, D: Send>(src: &[S], ss: usize, dst: &mut [D], ds: usize, f: impl Fn(&[S], &mut [D]) + Sync) {
let n = (src.len() / ss).min(dst.len() / ds);
f(&src[..n * ss], &mut dst[..n * ds]);
}
#[cfg(not(target_arch = "wasm32"))]
fn par_inplace(buf: &mut [f32], stride: usize, f: impl Fn(&mut [f32]) + Sync) {
use rayon::prelude::*;
if buf.len() / stride < PAR_MIN_PIXELS {
return f(buf);
}
buf.par_chunks_mut(PAR_CHUNK_PIXELS * stride).for_each(&f);
}
#[cfg(target_arch = "wasm32")]
fn par_inplace(buf: &mut [f32], _stride: usize, f: impl Fn(&mut [f32]) + Sync) {
f(buf)
}
// ------------------------------------------------------------ linking
/// Stages from `src` device values to `dst` device values.
pub(crate) fn link_stages(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Result<Vec<Stage>, CmsError> {
for p in [src, dst] {
// A Lab DeviceLink stands in for an abstract profile only when it also ends in Lab.
let lab_to_lab =
p.color_space == ColorSpace::Lab && p.a2b.iter().flatten().all(|l| l.outputs == 3) && p.b2a.iter().flatten().all(|l| l.inputs == 3);
if matches!(p.class, ProfileClass::DeviceLink | ProfileClass::NamedColor | ProfileClass::Abstract) && !lab_to_lab {
return Err(CmsError::Unsupported(format!("{:?} profiles cannot be used as a source or destination", p.class)));
}
}
let (mut stages, s_pcs) = src.device_to_pcs(intent)?;
let (d_stages, d_pcs) = dst.pcs_to_device(intent)?;
let adjust = connection(src, dst, intent, bpc)?;
match adjust {
Some((scale, offset)) => {
if s_pcs == Pcs::Lab {
stages.push(Stage::LabToXyz);
}
stages.push(Stage::Matrix {
rows: 3,
cols: 3,
m: vec![scale[0], 0.0, 0.0, 0.0, scale[1], 0.0, 0.0, 0.0, scale[2]],
offset: offset.to_vec(),
});
if d_pcs == Pcs::Lab {
stages.push(Stage::XyzToLab);
}
}
None => match (s_pcs, d_pcs) {
(Pcs::Lab, Pcs::Xyz) => stages.push(Stage::LabToXyz),
(Pcs::Xyz, Pcs::Lab) => stages.push(Stage::XyzToLab),
_ => {}
},
}
stages.extend(d_stages);
Ok(stages)
}
/// Per-component XYZ scale and offset applied in the PCS: absolute colorimetric media-white
/// scaling or black point compensation. `None` = identity.
/// Per-component XYZ `(scale, offset)`.
type Affine3 = ([f64; 3], [f64; 3]);
fn connection(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Result<Option<Affine3>, CmsError> {
if intent == Intent::AbsoluteColorimetric {
let (ws, wd) = (src.media_white(), dst.media_white());
// `wtpt` comes straight from the file. A zero, negative or non-finite component would
// make the scale NaN or infinite and poison every colour the transform produces, so a
// profile with such a media white is refused for this intent instead.
let usable = |w: &[f64; 3]| w.iter().all(|v| v.is_finite() && *v > 0.0);
if !usable(&ws) || !usable(&wd) {
return Err(CmsError::Invalid(format!("absolute colorimetric needs a positive media white point (source {ws:?}, destination {wd:?})")));
}
let s = [ws[0] / wd[0], ws[1] / wd[1], ws[2] / wd[2]];
if s.iter().all(|v| (v - 1.0).abs() < 1e-6) {
return Ok(None);
}
return Ok(Some((s, [0.0; 3])));
}
if !bpc {
return Ok(None);
}
let bs = black_point(src, intent, true)?;
let bd = black_point(dst, intent, false)?;
if (bs - bd).abs() < 1e-6 {
return Ok(None);
}
// Neutral black points (luminance only, as in Adobe's BPC), mapped linearly in XYZ so that
// the source black lands on the destination black and the white stays fixed.
let w = math::D50;
let mut scale = [1.0; 3];
let mut offset = [0.0; 3];
for i in 0..3 {
let (s, d) = (bs * w[i], bd * w[i]);
let a = (w[i] - d) / (w[i] - s);
scale[i] = a;
offset[i] = w[i] * (1.0 - a);
}
Ok(Some((scale, offset)))
}
/// Luminance (Y, D50-relative) of the profile's black point for `intent`.
///
/// Follows the structure of Adobe's published BPC algorithm: matrix/TRC profiles use device
/// zero; v4 LUT profiles in perceptual/saturation use the perceptual reference black; other LUT
/// profiles use the darkest colour the device reaches (Lab 0 round-tripped through BToA/AToB,
/// or device black for additive spaces). The destination-side curve fitting of the paper is
/// not implemented; the round-trip black is used directly.
pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result<f64, CmsError> {
if matches!(p.color_space, ColorSpace::Lab | ColorSpace::Xyz) {
return Ok(0.0);
}
let lut_based = p.a2b.iter().any(Option::is_some);
if lut_based && p.version.0 >= 4 && matches!(intent, Intent::Perceptual | Intent::Saturation) {
return Ok(PERCEPTUAL_BLACK[1]);
}
let pcs_y = |st: Vec<Stage>, pcs: Pcs, dev: &[f32]| -> f64 {
let pipe = Pipeline::new(p.channels(), st);
let mut o = [0.0f32; 16];
pipe.eval(dev, &mut o);
let y = match pcs {
Pcs::Xyz => o[1] as f64,
Pcs::Lab => math::lab_to_xyz([o[0] as f64, 0.0, 0.0], math::D50)[1],
};
y.clamp(0.0, 0.5)
};
let (to_pcs, pcs) = p.device_to_pcs(intent)?;
let additive = matches!(p.color_space, ColorSpace::Rgb | ColorSpace::Gray);
if additive || !lut_based {
let zero = vec![0.0f32; p.channels()];
return Ok(pcs_y(to_pcs, pcs, &zero));
}
// Subtractive LUT profile: darkest reachable colour.
let colorimetric = if intent == Intent::Saturation { Intent::Saturation } else { Intent::RelativeColorimetric };
match p.pcs_to_device(colorimetric) {
Ok((from_pcs, fpcs)) => {
let mut st = Vec::new();
if fpcs == Pcs::Xyz {
st.push(Stage::LabToXyz);
}
st.extend(from_pcs);
let pipe = Pipeline::new(3, st);
let mut dev = [0.0f32; 16];
pipe.eval(&[0.0, 0.0, 0.0], &mut dev);
Ok(pcs_y(to_pcs, pcs, &dev[..p.channels()]))
}
Err(_) => {
let ones = vec![1.0f32; p.channels()];
Ok(pcs_y(to_pcs, pcs, &ones))
}
}
}
// ------------------------------------------------------------ cache
type Key = (u64, u64, TransformOptions);
/// Process-wide cache of transforms keyed by profile content and options.
pub fn cached(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result<Arc<Transform>, CmsError> {
static CACHE: OnceLock<Mutex<HashMap<Key, Arc<Transform>>>> = OnceLock::new();
let key = (src.content_hash(), dst.content_hash(), opts);
let cache = CACHE.get_or_init(Default::default);
if let Some(t) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return Ok(t.clone());
}
let t = Arc::new(Transform::with_options(src, dst, opts)?);
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
if c.len() > 64 {
c.clear();
}
c.insert(key, t.clone());
Ok(t)
}
impl Clut {
/// Tetrahedral interpolation in one cube (crate-internal fast paths).
#[inline]
pub(crate) fn tetra_pub(&self, base: usize, s: [usize; 3], r: [f32; 3], out: &mut [f32; 16]) {
let d = &self.data;
let [rx, ry, rz] = r;
let [sx, sy, sz] = s;
let (p1, p2, p3, w1, w2, w3) = if rx >= ry {
if ry >= rz {
(sx, sx + sy, sx + sy + sz, rx, ry, rz)
} else if rx >= rz {
(sx, sx + sz, sx + sy + sz, rx, rz, ry)
} else {
(sz, sx + sz, sx + sy + sz, rz, rx, ry)
}
} else if rx >= rz {
(sy, sx + sy, sx + sy + sz, ry, rx, rz)
} else if ry >= rz {
(sy, sy + sz, sx + sy + sz, ry, rz, rx)
} else {
(sz, sy + sz, sx + sy + sz, rz, ry, rx)
};
let o = self.outputs.min(MAX_CHANNELS);
for k in 0..o {
let v0 = d[base + k];
let v1 = d[base + p1 + k];
let v2 = d[base + p2 + k];
let v3 = d[base + p3 + k];
out[k] = v0 + (v1 - v0) * w1 + (v2 - v1) * w2 + (v3 - v2) * w3;
}
}
}
+347
View File
@@ -0,0 +1,347 @@
//! ICC v4.3 profile writer (ICC.1:2010), used for built-in and synthetic profiles.
use crate::clut::Clut;
use crate::curve::Curve;
use crate::math;
use crate::pipeline::Stage;
use crate::profile::{LutKind, Pcs, Profile};
fn s15(v: f64) -> [u8; 4] {
((v * 65536.0).round() as i32).to_be_bytes()
}
fn xyz_type(v: [f64; 3]) -> Vec<u8> {
let mut o = b"XYZ \0\0\0\0".to_vec();
for c in v {
o.extend_from_slice(&s15(c));
}
o
}
fn mluc(text: &str) -> Vec<u8> {
let units: Vec<u16> = text.encode_utf16().collect();
let mut o = b"mluc\0\0\0\0".to_vec();
o.extend_from_slice(&1u32.to_be_bytes());
o.extend_from_slice(&12u32.to_be_bytes());
o.extend_from_slice(b"enUS");
o.extend_from_slice(&((units.len() * 2) as u32).to_be_bytes());
o.extend_from_slice(&28u32.to_be_bytes());
for u in units {
o.extend_from_slice(&u.to_be_bytes());
}
o
}
fn u16q(v: f32) -> u16 {
(v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16
}
/// `curv` or `para` element for a curve.
fn curve_elem(c: &Curve) -> Vec<u8> {
match c {
Curve::Identity => {
let mut o = b"curv\0\0\0\0".to_vec();
o.extend_from_slice(&0u32.to_be_bytes());
o
}
Curve::Gamma(g) => {
let mut o = b"para\0\0\0\0".to_vec();
o.extend_from_slice(&[0, 0, 0, 0]);
o.extend_from_slice(&s15(*g));
o
}
Curve::Parametric { kind, p } => {
let mut o = b"para\0\0\0\0".to_vec();
o.extend_from_slice(&kind.to_be_bytes());
o.extend_from_slice(&[0, 0]);
for v in p.iter().take(Curve::param_count(*kind)) {
o.extend_from_slice(&s15(*v));
}
o
}
Curve::Table(t) => {
let mut o = b"curv\0\0\0\0".to_vec();
o.extend_from_slice(&(t.len() as u32).to_be_bytes());
for v in t {
o.extend_from_slice(&u16q(*v).to_be_bytes());
}
o
}
}
}
fn pad4(v: &mut Vec<u8>) {
while !v.len().is_multiple_of(4) {
v.push(0);
}
}
fn curve_table(c: &Curve, n: usize) -> Vec<f32> {
match c {
Curve::Table(t) if t.len() == n => t.clone(),
_ => c.sample(n),
}
}
fn table_len(cs: &[Curve]) -> usize {
cs.iter()
.map(|c| match c {
Curve::Table(t) => t.len(),
Curve::Identity => 2,
_ => 1024,
})
.max()
.unwrap_or(2)
.clamp(2, 4096)
}
/// Splits a LUT's stages into `(matrix, input curves, clut, output curves)` for `mft1`/`mft2`.
type LutParts<'a> = (Option<Vec<f64>>, &'a [Curve], &'a Clut, &'a [Curve]);
fn lut_parts(stages: &[Stage]) -> Option<LutParts<'_>> {
let mut i = 0;
let mut mat = None;
if let Some(Stage::Matrix { rows: 3, cols: 3, m, .. }) = stages.first() {
mat = Some(m.clone());
i = 1;
}
match &stages[i..] {
[Stage::Curves(a), Stage::Clut(c), Stage::Curves(b)] => Some((mat, a, c, b)),
_ => None,
}
}
/// `None` unless the stages are `[matrix?] curves, clut, curves` with a uniform grid.
fn encode_mft(stages: &[Stage], wide: bool) -> Option<Vec<u8>> {
let (mat, a, clut, b) = lut_parts(stages)?;
let g = *clut.grid.first()?;
if !clut.grid.iter().all(|x| *x == g) {
return None;
}
let mut o = if wide { b"mft2\0\0\0\0".to_vec() } else { b"mft1\0\0\0\0".to_vec() };
o.extend_from_slice(&[clut.inputs as u8, clut.outputs as u8, g as u8, 0]);
let m = mat.unwrap_or_else(|| vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
for v in &m {
o.extend_from_slice(&s15(*v));
}
let (n_in, n_out) = if wide { (table_len(a), table_len(b)) } else { (256, 256) };
if wide {
o.extend_from_slice(&(n_in as u16).to_be_bytes());
o.extend_from_slice(&(n_out as u16).to_be_bytes());
}
let put = |o: &mut Vec<u8>, v: f32| {
if wide {
o.extend_from_slice(&u16q(v).to_be_bytes());
} else {
o.push((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
}
};
for c in a {
for v in curve_table(c, n_in) {
put(&mut o, v);
}
}
for v in &clut.data {
put(&mut o, *v);
}
for c in b {
for v in curve_table(c, n_out) {
put(&mut o, v);
}
}
Some(o)
}
/// `None` when the stages aren't a layout `lutAtoB`/`lutBtoA` can store in this direction.
fn encode_ab(stages: &[Stage], a2b: bool, inputs: usize, outputs: usize) -> Option<Vec<u8>> {
let mut a_curves: Option<&[Curve]> = None;
let mut clut: Option<&Clut> = None;
let mut m_curves: Option<&[Curve]> = None;
let mut matrix: Option<(&Vec<f64>, &Vec<f64>)> = None;
let mut b_curves: Option<&[Curve]> = None;
let mut it = stages.iter().peekable();
if a2b {
// [A, CLUT] [M, matrix] B
if let (Some(Stage::Curves(a)), Some(Stage::Clut(_))) = (stages.first(), stages.get(1)) {
a_curves = Some(a);
it.next();
if let Some(Stage::Clut(c)) = it.next() {
clut = Some(c);
}
}
let rest: Vec<&Stage> = it.collect();
match rest.as_slice() {
[Stage::Curves(m), Stage::Matrix { m: mm, offset, .. }, Stage::Curves(b)] => {
m_curves = Some(m.as_slice());
matrix = Some((mm, offset));
b_curves = Some(b.as_slice());
}
[Stage::Curves(b)] => b_curves = Some(b.as_slice()),
_ => return None,
}
} else {
// B [matrix, M] [CLUT, A]
let v: Vec<&Stage> = it.collect();
let mut k = 0;
if let Some(Stage::Curves(b)) = v.first() {
b_curves = Some(b.as_slice());
k = 1;
}
if let (Some(Stage::Matrix { m: mm, offset, .. }), Some(Stage::Curves(m))) = (v.get(k), v.get(k + 1)) {
matrix = Some((mm, offset));
m_curves = Some(m.as_slice());
k += 2;
}
if let (Some(Stage::Clut(c)), Some(Stage::Curves(a))) = (v.get(k), v.get(k + 1)) {
clut = Some(c);
a_curves = Some(a.as_slice());
k += 2;
}
if k != v.len() {
return None;
}
}
let mut o = if a2b { b"mAB \0\0\0\0".to_vec() } else { b"mBA \0\0\0\0".to_vec() };
o.extend_from_slice(&[inputs as u8, outputs as u8, 0, 0]);
o.extend_from_slice(&[0u8; 20]); // offsets, patched below
let mut offs = [0u32; 5]; // B, matrix, M, CLUT, A
let curves = |o: &mut Vec<u8>, cs: &[Curve]| {
for c in cs {
o.extend_from_slice(&curve_elem(c));
pad4(o);
}
};
let b = b_curves?;
offs[0] = o.len() as u32;
curves(&mut o, b);
if let Some((mm, off)) = matrix {
offs[1] = o.len() as u32;
for v in mm.iter() {
o.extend_from_slice(&s15(*v));
}
for v in off.iter() {
o.extend_from_slice(&s15(*v));
}
offs[2] = o.len() as u32;
curves(&mut o, m_curves?);
}
if let Some(c) = clut {
offs[3] = o.len() as u32;
let mut g = [0u8; 16];
for (k, n) in c.grid.iter().enumerate() {
g[k] = *n as u8;
}
o.extend_from_slice(&g);
o.extend_from_slice(&[2, 0, 0, 0]);
for v in &c.data {
o.extend_from_slice(&u16q(*v).to_be_bytes());
}
pad4(&mut o);
offs[4] = o.len() as u32;
curves(&mut o, a_curves?);
}
for (k, v) in offs.iter().enumerate() {
o[12 + k * 4..16 + k * 4].copy_from_slice(&v.to_be_bytes());
}
Some(o)
}
/// Encodes `p` as ICC v4.3 bytes (identical tag data is shared between tags). A LUT whose
/// stage layout its tag type can't store (only possible for a parsed profile whose LUT tag
/// used the other direction's type) is left out; the matrix/TRC tags still describe it.
pub fn encode(p: &Profile) -> Vec<u8> {
let mut tags: Vec<([u8; 4], Vec<u8>)> = Vec::new();
tags.push((*b"desc", mluc(&p.description)));
tags.push((*b"cprt", mluc(&p.copyright)));
tags.push((*b"wtpt", xyz_type(p.white_point)));
if let Some(bk) = p.black_point {
tags.push((*b"bkpt", xyz_type(bk)));
}
if let Some(c) = &p.chad {
let mut o = b"sf32\0\0\0\0".to_vec();
for v in c.iter().flatten() {
o.extend_from_slice(&s15(*v));
}
tags.push((*b"chad", o));
}
if let Some(m) = &p.matrix {
tags.push((*b"rXYZ", xyz_type([m[0][0], m[1][0], m[2][0]])));
tags.push((*b"gXYZ", xyz_type([m[0][1], m[1][1], m[2][1]])));
tags.push((*b"bXYZ", xyz_type([m[0][2], m[1][2], m[2][2]])));
}
if let Some([r, g, b]) = &p.trc {
tags.push((*b"rTRC", curve_elem(r)));
tags.push((*b"gTRC", curve_elem(g)));
tags.push((*b"bTRC", curve_elem(b)));
}
if let Some(k) = &p.gray_trc {
tags.push((*b"kTRC", curve_elem(k)));
}
for (luts, names, a2b) in [(&p.a2b, [b"A2B0", b"A2B1", b"A2B2"], true), (&p.b2a, [b"B2A0", b"B2A1", b"B2A2"], false)] {
for (l, name) in luts.iter().zip(names) {
if let Some(l) = l {
let data = match l.kind {
LutKind::Lut16 => encode_mft(&l.stages, true),
LutKind::Lut8 => encode_mft(&l.stages, false),
LutKind::Ab => encode_ab(&l.stages, a2b, l.inputs, l.outputs),
};
if let Some(data) = data {
tags.push((*name, data));
}
}
}
}
// Layout: header, tag table, then data (identical payloads share one copy).
let table_len = 4 + tags.len() * 12;
let mut data: Vec<u8> = Vec::new();
let mut entries: Vec<([u8; 4], u32, u32)> = Vec::new();
let mut placed: Vec<(usize, u32, u32)> = Vec::new(); // (tag index, offset, len)
for (i, (sig, d)) in tags.iter().enumerate() {
if let Some((_, off, len)) = placed.iter().find(|(j, _, _)| tags[*j].1 == *d) {
entries.push((*sig, *off, *len));
continue;
}
let off = (128 + table_len + data.len()) as u32;
data.extend_from_slice(d);
pad4(&mut data);
entries.push((*sig, off, d.len() as u32));
placed.push((i, off, d.len() as u32));
}
let size = 128 + table_len + data.len();
let mut o = Vec::with_capacity(size);
o.extend_from_slice(&(size as u32).to_be_bytes());
o.extend_from_slice(&[0, 0, 0, 0]); // preferred CMM
o.extend_from_slice(&[4, 0x30, 0, 0]); // version 4.3
o.extend_from_slice(&p.class.sig().to_be_bytes());
o.extend_from_slice(&p.color_space.sig().to_be_bytes());
o.extend_from_slice(match p.pcs {
Pcs::Xyz => b"XYZ ",
Pcs::Lab => b"Lab ",
});
for v in [2026u16, 1, 1, 0, 0, 0] {
o.extend_from_slice(&v.to_be_bytes());
}
o.extend_from_slice(b"acsp");
o.extend_from_slice(&[0; 4]); // platform
o.extend_from_slice(&[0; 4]); // flags
o.extend_from_slice(&[0; 4]); // manufacturer
o.extend_from_slice(&[0; 4]); // model
o.extend_from_slice(&[0; 8]); // attributes
o.extend_from_slice(&(p.rendering_intent as u32).to_be_bytes());
for v in math::D50 {
o.extend_from_slice(&s15(v));
}
o.extend_from_slice(&[0; 4]); // creator
o.extend_from_slice(&[0; 16]); // profile id (not computed)
o.resize(128, 0);
o.extend_from_slice(&(entries.len() as u32).to_be_bytes());
for (sig, off, len) in &entries {
o.extend_from_slice(sig);
o.extend_from_slice(&off.to_be_bytes());
o.extend_from_slice(&len.to_be_bytes());
}
o.extend_from_slice(&data);
debug_assert_eq!(o.len(), size);
o
}
+29
View File
@@ -0,0 +1,29 @@
//! Throughput check: `cargo test -p craft-cms --release --test bench -- --ignored --nocapture`.
use craft_cms::{Builtin, Intent, Transform};
#[test]
#[ignore]
fn bench_srgb8_to_cmyk8() {
let t0 = std::time::Instant::now();
let t = Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).unwrap();
eprintln!("build: {:?}", t0.elapsed());
let n = 6016 * 6016;
let mut src = vec![0u8; n * 4];
let mut x = 1u32;
for v in src.iter_mut() {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*v = x as u8;
}
let mut dst = vec![0u8; n * 5];
let t1 = std::time::Instant::now();
t.convert_u8(&src, 4, &mut dst, 5, true);
eprintln!("convert 6016² RGBA8 → CMYKA8 (one buffer): {:?}", t1.elapsed());
let t2 = std::time::Instant::now();
for (s, d) in src.chunks(65536 * 4).zip(dst.chunks_mut(65536 * 5)) {
t.convert_u8(s, 4, d, 5, true);
}
eprintln!("convert per 256² tile: {:?}", t2.elapsed());
}
+826
View File
@@ -0,0 +1,826 @@
//! 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 craft_cms::math::{self, delta_e76};
use craft_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:?}");
}
/// `Builtin::Srgb`'s bytes (a v4 display profile) with the three `wtpt` values replaced.
fn srgb_with_wtpt(xyz: [i32; 3]) -> Profile {
let mut b = Builtin::Srgb.profile().to_bytes().to_vec();
let be32 = |b: &[u8], at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) as usize;
let count = be32(&b, 128);
let rec = (0..count).map(|i| 132 + 12 * i).find(|&r| &b[r..r + 4] == b"wtpt").expect("sRGB has a wtpt tag");
let off = be32(&b, rec + 4);
assert_eq!(&b[off..off + 4], b"XYZ ");
for (i, v) in xyz.iter().enumerate() {
b[off + 8 + 4 * i..off + 12 + 4 * i].copy_from_slice(&v.to_be_bytes());
}
Profile::parse(&b).expect("still a valid profile")
}
/// A malformed `wtpt` (zero or negative media white) parses, and used to turn the absolute
/// colorimetric media-white scale into NaN, so every colour through the transform (and through
/// a paper-simulating proof built from it) came out NaN. It is now refused when the transform
/// is built; the other intents, which never divide by the white, still work.
#[test]
fn absolute_colorimetric_refuses_a_degenerate_media_white() {
let mut zero = srgb().clone();
zero.white_point = [0.0; 3];
assert_eq!(zero.media_white(), [0.0; 3]);
let parsed_zero = srgb_with_wtpt([0, 0, 0]);
assert_eq!(parsed_zero.media_white(), [0.0; 3], "a v4 display profile's wtpt is used verbatim");
let negative = srgb_with_wtpt([-0x10000, 0x10000, 0x10000]);
let mut nan = srgb().clone();
nan.white_point = [f64::NAN, 1.0, 1.0];
for (name, bad) in [("zero", &zero), ("parsed zero", &parsed_zero), ("negative", &negative), ("nan", &nan)] {
for (src, dst) in [(srgb(), bad), (bad, srgb())] {
match Transform::new(src, dst, Intent::AbsoluteColorimetric, false) {
Err(craft_cms::CmsError::Invalid(m)) => assert!(m.contains("media white"), "{name}: {m}"),
Err(e) => panic!("{name}: wrong error {e}"),
Ok(t) => {
let mut out = [0.0f32; 3];
t.eval(&[0.2, 0.4, 0.6], &mut out);
panic!("{name}: built a transform that gives {out:?}");
}
}
}
// Proof Colors with Simulate Paper Color proofs through the absolute intent.
assert!(Transform::proof(srgb(), bad, srgb(), Intent::AbsoluteColorimetric, false, true).is_err(), "{name}: paper proof");
assert!(Transform::proof(srgb(), bad, srgb(), Intent::RelativeColorimetric, true, true).is_err(), "{name}: paper proof, relative first leg");
// Without paper simulation the proof leg is relative colorimetric and stays finite.
let t = Transform::proof(srgb(), bad, srgb(), Intent::RelativeColorimetric, true, false).unwrap();
let mut out = [0.0f32; 3];
t.eval(&[0.2, 0.4, 0.6], &mut out);
assert!(out.iter().all(|v| v.is_finite()), "{name}: {out:?}");
let t = Transform::new(srgb(), bad, Intent::RelativeColorimetric, false).unwrap();
t.eval(&[0.2, 0.4, 0.6], &mut out);
assert!(out.iter().all(|v| v.is_finite()), "{name}: {out:?}");
}
// A sane patched white still builds and scales.
let d50 = srgb_with_wtpt([0xF6D6, 0x10000, 0xD32D]);
let t = Transform::new(srgb(), &d50, Intent::AbsoluteColorimetric, false).unwrap();
let mut out = [0.0f32; 3];
t.eval(&[0.2, 0.4, 0.6], &mut out);
assert!(out.iter().all(|v| v.is_finite()), "{out:?}");
}
#[test]
fn black_point_compensation() {
let bp = craft_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!(craft_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);
}
}
}
// A press's full-K lightness depends on its ink and paper (newsprint can exceed L* 40).
// Keep the relative paper-white check and require finite endpoints with darker, bounded ink.
fn cmyk_endpoints_valid(paper: [f64; 3], black: [f64; 3]) -> bool {
// Allow only float rounding at the physical L* limits, not a different printing condition.
let lightness = -1e-4..=100.0 + 1e-4;
paper.iter().chain(&black).all(|v| v.is_finite())
&& lightness.contains(&paper[0])
&& lightness.contains(&black[0])
&& paper[0] > 90.0
&& black[0] < paper[0]
}
#[test]
fn a_light_black_cmyk_lut_has_the_known_relative_ramp() {
use craft_cms::Curve;
use craft_cms::pipeline::Stage;
use craft_cms::profile::{Lut, LutKind};
let mut p = cmyk().clone();
// Independent linear test model: L* = 100 - 52 K, neutral a*/b*. Exercise A2B1 only;
// the inherited coated profile's inverse is not an inverse of this generated model.
p.a2b = [None, None, None];
p.b2a = [None, None, None];
p.a2b[1] = Some(Lut {
kind: LutKind::Ab,
inputs: 4,
outputs: 3,
stages: vec![
Stage::Curves(vec![Curve::Identity; 4]),
Stage::Clut(Clut::sample(vec![2; 4], 3, |ink, out| {
out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0]));
})),
Stage::Curves(vec![Curve::Identity; 3]),
],
});
let p = Profile::parse(&p.with_encoded_bytes().to_bytes()).unwrap();
let paper = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
let black = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
assert!(cmyk_endpoints_valid(paper, black), "{paper:?} {black:?}");
// Explicit expected values, not the validation predicate: also catch wrong channels,
// intent selection, constant output and incorrect LUT encoding/interpolation.
for (k, expected) in [(0.0, 100.0), (0.25, 87.0), (0.5, 74.0), (0.75, 61.0), (1.0, 48.0)] {
let actual = to_lab(&p, &[0.0, 0.0, 0.0, k], Intent::RelativeColorimetric);
// The writer quantizes the CLUT to 16 bits: at most 0.000763 L* error.
assert!((actual[0] - expected).abs() < 0.001 && actual[1].abs() < 1e-4 && actual[2].abs() < 1e-4, "K={k}: {actual:?}");
}
}
#[test]
fn cmyk_endpoints_reject_invalid_values() {
let paper = [100.0, 0.0, 0.0];
let black = [48.0, 0.0, 0.0];
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for channel in 0..3 {
let mut p = paper;
let mut k = black;
p[channel] = bad;
k[channel] = bad;
assert!(!cmyk_endpoints_valid(p, black));
assert!(!cmyk_endpoints_valid(paper, k));
}
}
for (p, k) in [(90.0, 48.0), (100.01, 48.0), (100.0, -0.01), (100.0, 100.01), (100.0, 100.0), (99.0, 100.0)] {
assert!(!cmyk_endpoints_valid([p, 0.0, 0.0], [k, 0.0, 0.0]), "L*: paper {p}, black {k}");
}
}
/// 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,
craft_cms::ProfileClass::Input
| craft_cms::ProfileClass::Display
| craft_cms::ProfileClass::Output
| craft_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!(cmyk_endpoints_valid(w, k), "{}: {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}");
}
#[test]
fn builtin_all_in_declaration_order() {
// `Builtin::profile` indexes its cache by discriminant.
for (i, b) in Builtin::ALL.iter().enumerate() {
assert_eq!(*b as usize, i, "{b:?}");
}
}
/// `icc` with one more tag appended (the tag table grows by 12 bytes, so offsets shift).
fn with_tag(icc: &[u8], sig: &[u8; 4], data: &[u8]) -> Vec<u8> {
let be = |o: usize| u32::from_be_bytes(icc[o..o + 4].try_into().unwrap());
let count = be(128) as usize;
let table_end = 132 + count * 12;
let mut body = icc[table_end..].to_vec();
while !body.len().is_multiple_of(4) {
body.push(0);
}
let mut out = icc[..128].to_vec();
out.extend_from_slice(&(count as u32 + 1).to_be_bytes());
for i in 0..count {
let o = 132 + i * 12;
out.extend_from_slice(&icc[o..o + 4]);
out.extend_from_slice(&(be(o + 4) + 12).to_be_bytes());
out.extend_from_slice(&icc[o + 8..o + 12]);
}
let new_off = (table_end + 12 + body.len()) as u32;
out.extend_from_slice(sig);
out.extend_from_slice(&new_off.to_be_bytes());
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(&body);
out.extend_from_slice(data);
let size = out.len() as u32;
out[..4].copy_from_slice(&size.to_be_bytes());
out
}
/// A `lutBtoA` ("mBA ") tag, 1 → 3 channels, using every element:
/// B curves, matrix, M curves, CLUT, A curves.
fn mba_1_to_3() -> Vec<u8> {
let curv = b"curv\0\0\0\0\0\0\0\0"; // identity curve, 12 bytes
let mut d = b"mBA \0\0\0\0".to_vec();
d.extend_from_slice(&[1, 3, 0, 0]);
// offsets: B, matrix, M, CLUT, A
for off in [32u32, 44, 92, 104, 132] {
d.extend_from_slice(&off.to_be_bytes());
}
d.extend_from_slice(curv); // B @ 32
for k in 0..12 {
let v: i32 = if k % 4 == 0 && k < 9 { 0x10000 } else { 0 };
d.extend_from_slice(&v.to_be_bytes()); // matrix @ 44 (identity, zero offset)
}
d.extend_from_slice(curv); // M @ 92
let mut grid = [0u8; 16];
grid[0] = 2;
d.extend_from_slice(&grid); // CLUT @ 104
d.extend_from_slice(&[1, 0, 0, 0]); // 8-bit precision
d.extend_from_slice(&[0, 0, 0, 255, 255, 255]); // 2 nodes x 3 outputs
d.extend_from_slice(&[0, 0]); // pad to 132
for _ in 0..3 {
d.extend_from_slice(curv); // A @ 132
}
d
}
/// Regression: a gray profile whose A2B0 tag is stored as "mBA " parsed fine, but
/// re-encoding its RGB view (`gray_as_rgb`, used for gray documents' composites) hit
/// `panic!("unsupported lutAtoB stage layout")` in the ICC writer.
#[test]
fn gray_profile_with_reversed_lut_type_reencodes() {
let sgray = Builtin::SGray.profile().to_bytes();
let icc = with_tag(&sgray, b"A2B0", &mba_1_to_3());
let p = Profile::parse(&icc).unwrap();
assert!(p.a2b[0].is_some(), "the crafted A2B0 tag should parse");
let rgb = p.gray_as_rgb().unwrap();
assert!(rgb.is_matrix_shaper(), "RGB view keeps no gray LUTs");
let back = Profile::parse(&rgb.to_bytes()).unwrap();
assert_eq!(back.color_space, ColorSpace::Rgb);
// The writer itself must not panic on a LUT it can't store either.
let mut odd = p.clone();
odd.description = "re-encoded".into();
let _ = Profile::parse(&odd.with_encoded_bytes().to_bytes()).unwrap();
}
/// sRGB the way Photoshop embeds it: a v2 profile whose TRCs are 1024-entry 16-bit tables.
fn srgb_as_v2_tables() -> Profile {
let mut p = srgb().clone();
let table = |c: &craft_cms::Curve| {
craft_cms::Curve::Table((0..1024).map(|i| ((c.eval64(i as f64 / 1023.0) * 65535.0).round() / 65535.0) as f32).collect())
};
let trc = p.trc.clone().unwrap();
p.trc = Some([table(&trc[0]), table(&trc[1]), table(&trc[2])]);
p.version = (2, 0x10);
p.with_encoded_bytes()
}
#[test]
fn same_colors_ignores_the_encoding() {
let v2 = Profile::parse(&srgb_as_v2_tables().to_bytes()).unwrap();
assert_ne!(v2.content_hash(), srgb().content_hash(), "different bytes");
assert!(v2.same_colors(srgb()) && srgb().same_colors(&v2));
for b in Builtin::ALL {
assert!(b.profile().same_colors(b.profile()), "{b:?}");
// Re-encoded (fresh bytes, same model): CMYK too, where a round trip is not an identity.
let again = Profile::parse(&b.profile().clone().with_encoded_bytes().to_bytes()).unwrap();
assert!(again.same_colors(b.profile()), "{b:?} re-encoded");
}
}
#[test]
fn same_colors_tells_different_spaces_apart() {
for other in [Builtin::DisplayP3, Builtin::LinearSrgb, Builtin::AdobeRgbCompat, Builtin::Rec2020, Builtin::CoatedCmyk, Builtin::GrayGamma22] {
assert!(!srgb().same_colors(other.profile()), "{other:?}");
}
assert!(!Builtin::GrayGamma22.profile().same_colors(Builtin::SGray.profile()));
// Scaling the red primary's XYZ by 2% makes a different space too.
let mut moved = srgb().clone();
let m = moved.matrix.as_mut().unwrap();
for row in m.iter_mut() {
row[0] *= 1.02;
}
assert!(!moved.with_encoded_bytes().same_colors(srgb()));
}
/// The sRGB IEC61966-2.1 profile Windows ships (the one Photoshop embeds), when present.
#[test]
fn windows_srgb_is_the_builtin_srgb() {
let Ok(bytes) = std::fs::read("C:/Windows/System32/spool/drivers/color/sRGB Color Space Profile.icm") else { return };
let p = Profile::parse(&bytes).unwrap();
assert_ne!(p.content_hash(), srgb().content_hash());
assert!(p.same_colors(srgb()));
}
/// A minimal DeviceLink: header plus one `A2B0` (`mft2`, 2-point grid) mapping `inputs` channels
/// of `space` to `outputs` channels of `out_space`, which the header stores in the PCS field.
fn device_link(space: &[u8; 4], out_space: &[u8; 4], inputs: usize, outputs: usize) -> Vec<u8> {
let mut lut = b"mft2\0\0\0\0".to_vec();
lut.extend_from_slice(&[inputs as u8, outputs as u8, 2, 0]);
for k in 0..9 {
let v: i32 = if k % 4 == 0 { 0x10000 } else { 0 };
lut.extend_from_slice(&v.to_be_bytes()); // identity matrix
}
lut.extend_from_slice(&[0, 2, 0, 2]); // 2-entry input and output tables
for _ in 0..inputs {
lut.extend_from_slice(&[0, 0, 0xff, 0xff]);
}
for _ in 0..(1 << inputs) * outputs {
lut.extend_from_slice(&[0x80, 0]);
}
for _ in 0..outputs {
lut.extend_from_slice(&[0, 0, 0xff, 0xff]);
}
let mut icc = vec![0u8; 128];
icc[8] = 4;
icc[9] = 0x30;
icc[12..16].copy_from_slice(b"link");
icc[16..20].copy_from_slice(space);
icc[20..24].copy_from_slice(out_space);
icc[36..40].copy_from_slice(b"acsp");
icc.extend_from_slice(&1u32.to_be_bytes());
icc.extend_from_slice(b"A2B0");
icc.extend_from_slice(&144u32.to_be_bytes());
icc.extend_from_slice(&(lut.len() as u32).to_be_bytes());
icc.extend_from_slice(&lut);
let size = icc.len() as u32;
icc[..4].copy_from_slice(&size.to_be_bytes());
icc
}
/// Regression (#2337): DeviceLinks whose output isn't a 3-channel space were rejected as
/// invalid, because the LUT check assumed every AToB tag ends in the PCS. They parse now and
/// get the clear "unsupported" error when used for a conversion, including a Lab→CMYK link,
/// which must not pass for a Lab-to-Lab one.
#[test]
fn device_links_parse_and_are_reported_unsupported() {
for (space, out_space, inputs, outputs) in
[(b"CMYK", b"CMYK", 4, 4), (b"RGB ", b"CMYK", 3, 4), (b"Lab ", b"CMYK", 3, 4), (b"RGB ", b"RGB ", 3, 3)]
{
let p = Profile::parse(&device_link(space, out_space, inputs, outputs)).unwrap();
assert_eq!(p.color_space.channels(), inputs);
for err in [Transform::new(&p, srgb(), Intent::Perceptual, false).err(), Transform::new(srgb(), &p, Intent::Perceptual, false).err()] {
assert!(matches!(err, Some(craft_cms::CmsError::Unsupported(_))), "{err:?}");
}
}
// The output space still bounds the tag: a CMYK→CMYK link with a 4→3 table is malformed.
assert!(matches!(Profile::parse(&device_link(b"CMYK", b"CMYK", 4, 3)), Err(craft_cms::CmsError::Invalid(_))));
}
+44
View File
@@ -0,0 +1,44 @@
//! Regenerates the shipped synthetic CMYK profile when `CRAFT_CMS_REGEN_PROFILES=1`:
//!
//! ```sh
//! CRAFT_CMS_REGEN_PROFILES=1 cargo test -p craft-cms --release --test regen
//! ```
//!
//! Without the variable the test only checks that the generator's model still matches the
//! shipped AToB table (cheap), so model edits are not forgotten.
use craft_cms::synth::{CmykModel, CmykParams};
use craft_cms::{Builtin, Intent, Profile, Transform};
#[test]
fn regen_or_check_coated_cmyk() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/profiles/photocraft-coated-cmyk.icc");
if std::env::var_os("CRAFT_CMS_REGEN_PROFILES").is_some() {
let t = std::time::Instant::now();
let p = craft_cms::synth::coated_cmyk();
let bytes = p.to_bytes();
std::fs::write(path, &*bytes).unwrap();
eprintln!("wrote {} bytes in {:?}", bytes.len(), t.elapsed());
return;
}
// The shipped AToB (colorimetric) must reproduce the model.
let shipped = Profile::parse(&std::fs::read(path).unwrap()).unwrap();
let lab = Builtin::LabD50.profile();
let t = Transform::new(&shipped, lab, Intent::RelativeColorimetric, false).unwrap();
let model = CmykModel::coated(CmykParams::default().tvi);
let mut worst = 0.0f64;
for c in [0.0, 0.3, 0.7, 1.0] {
for m in [0.0, 0.45, 1.0] {
for y in [0.0, 0.55, 1.0] {
for k in [0.0, 0.5, 1.0] {
let mut o = [0.0f32; 3];
t.eval(&[c as f32, m as f32, y as f32, k as f32], &mut o);
let got = [o[0] as f64 * 100.0, o[1] as f64 * 255.0 - 128.0, o[2] as f64 * 255.0 - 128.0];
let want = model.lab([c, m, y, k]);
worst = worst.max(craft_cms::math::delta_e76(got, want));
}
}
}
}
assert!(worst < 1.0, "shipped CMYK profile is stale (max ΔE {worst}); regenerate it");
}