mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-13 15:09:09 +00:00
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:
+2








committed 2026-10-13 01:23:45 +00:00
1 parent
666c358065
commit
dd9e3f1bc3
24 files changed
+5533
-2
No files matched your search
+3
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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(_))));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user