mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
craft-cms: the changes PhotoCraft made to its copy since extraction (#858)
* craft-cms: the changes PhotoCraft made to its copy since extraction PhotoCraft kept editing crates/cms after craft-cms was extracted from it at storytold/photocraft@b3222ee8. This ports its 41 commits up to storytold/photocraft@0143b148 with a three-way merge per file, keeping the changes craft-cms made on its own (Result-returning API, white preservation, FOGRA39, named colours, profile ID). Ported: the .3dl shaper, Mesh header, UTF-16 and .look fixes in lutfile; the checked ICC offsets and the per-profile curve sample budget; same_bytes and the byte-confirmed transform cache; the blocked matrix/TRC path and the 8-bit first-stage table; GpuMatrixShaper; cicp_rgb; the ACEScg and Dot Gain 20 % built-ins (the latter now the default gray); bisection for a table's flat ends; PhotoCraft spelled with a capital C in the built-in descriptions, with the shipped coated CMYK profile's bytes as PhotoCraft regenerated them. Not taken, because craft-cms already does it another way: Curve::inverse_table (inverse64_many), the effective-dimension loop in multilinear interpolation, the curve-count and mluc checks, and the .cube DOMAIN writer. The 8-bit first-stage table checks device white as the exact path does. Refs #779 * craft-cms: Lut3d::from_fn PhotoCraft's proof and HDR display tables are built from a chain of several transforms, which it wrote into Lut3d's fields. Those are private here, so from_fn samples any RGB function, with the same size bounds as from_transform. Refs #779 * craft-cms: README names Lut3d::from_fn
This commit is contained in:
1 parent
e4a6b68e5a
commit
628a235ae5
16 files changed
+1620
-94
No files matched your search
+17
-4
@@ -30,8 +30,14 @@ Buffers are checked once per call, before any pixel is written.
|
||||
|
||||
* `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.
|
||||
* `Lut3d::from_transform(&t, 33)?.to_rgba16f_bytes()`: display transform as a 3D texture;
|
||||
`Lut3d::from_fn(size, f)` samples any RGB function.
|
||||
* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for
|
||||
display shaders. Float tables retain near-black detail; unsupported chains return `None`
|
||||
for the 3D LUT fallback. No GPU dependency is required by this crate.
|
||||
* `transform::cached(src, dst, opts)`: process-wide transform cache.
|
||||
* `builtin::cicp_rgb(primaries, transfer, linear)`: the RGB profile for an ITU-T H.273 colour
|
||||
description (a HEIF `nclx` box, a video stream).
|
||||
* `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
|
||||
@@ -59,6 +65,10 @@ Buffers are checked once per call, before any pixel is written.
|
||||
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).
|
||||
* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus
|
||||
offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated
|
||||
separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB
|
||||
33³ table cannot resolve steep inverse gamma curves near black, even with float texels.
|
||||
* 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
|
||||
@@ -73,7 +83,8 @@ Buffers are checked once per call, before any pixel is written.
|
||||
## 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.
|
||||
published with (spelled "PhotoCraft" since PhotoCraft's #5614), so profiles embedded in
|
||||
existing files still match byte for byte.
|
||||
|
||||
| id | description |
|
||||
|---|---|
|
||||
@@ -82,10 +93,12 @@ published with, so profiles embedded in existing files still match byte for byte
|
||||
| `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 |
|
||||
| `aces-cg` | ACEScg: AP1 primaries, ACES white (~D60), 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) |
|
||||
| `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve |
|
||||
| `dot-gain-20-compat` | gray, gamma 1.732, fitted to Photoshop's Dot Gain 20 % (default gray working space) |
|
||||
| `lab-d50` | Lab identity (v4 encoding) |
|
||||
| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)**: default CMYK |
|
||||
| `coated-cmyk` | **PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)**: default CMYK |
|
||||
| `coated-fogra39` | Coated FOGRA39 (fitted, craft-cms): fitted to Fogra's FOGRA39 data, 330% TAC, medium GCR |
|
||||
|
||||
### The CMYK profiles
|
||||
|
||||
Binary file not shown.
+99
-18
@@ -34,12 +34,18 @@ pub enum Builtin {
|
||||
ProPhotoCompat,
|
||||
/// sRGB primaries with a linear transfer function.
|
||||
LinearSrgb,
|
||||
/// ACEScg: AP1 primaries, the ACES white point (~D60), linear transfer. The ACES working space for CG renders.
|
||||
AcesCg,
|
||||
/// 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,
|
||||
/// Gray matching Photoshop's default working gray (Dot Gain 20 %) with our own curve: a pure
|
||||
/// gamma of 1.732, fitted on Photoshop 27.11's RGB → Grayscale (sRGB 64 / 128 / 192 and pure red
|
||||
/// become 46 / 105 / 177 / 107, all within 1 level). Not Adobe's profile.
|
||||
DotGain20Compat,
|
||||
/// 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`].
|
||||
@@ -51,15 +57,17 @@ pub enum Builtin {
|
||||
}
|
||||
|
||||
impl Builtin {
|
||||
pub const ALL: [Builtin; 11] = [
|
||||
pub const ALL: [Builtin; 13] = [
|
||||
Builtin::Srgb,
|
||||
Builtin::DisplayP3,
|
||||
Builtin::AdobeRgbCompat,
|
||||
Builtin::ProPhotoCompat,
|
||||
Builtin::LinearSrgb,
|
||||
Builtin::AcesCg,
|
||||
Builtin::Rec2020,
|
||||
Builtin::GrayGamma22,
|
||||
Builtin::SGray,
|
||||
Builtin::DotGain20Compat,
|
||||
Builtin::LabD50,
|
||||
Builtin::CoatedCmyk,
|
||||
Builtin::CoatedFogra39,
|
||||
@@ -73,9 +81,11 @@ impl Builtin {
|
||||
Builtin::AdobeRgbCompat => "adobe-rgb-compat",
|
||||
Builtin::ProPhotoCompat => "prophoto-compat",
|
||||
Builtin::LinearSrgb => "linear-srgb",
|
||||
Builtin::AcesCg => "aces-cg",
|
||||
Builtin::Rec2020 => "rec2020",
|
||||
Builtin::GrayGamma22 => "gray-gamma-2.2",
|
||||
Builtin::SGray => "sgray",
|
||||
Builtin::DotGain20Compat => "dot-gain-20-compat",
|
||||
Builtin::LabD50 => "lab-d50",
|
||||
Builtin::CoatedCmyk => "coated-cmyk",
|
||||
Builtin::CoatedFogra39 => "coated-fogra39",
|
||||
@@ -87,13 +97,15 @@ impl Builtin {
|
||||
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::AdobeRgbCompat => "Adobe RGB (1998)-compatible (PhotoCraft)",
|
||||
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, PhotoCraft)",
|
||||
Builtin::LinearSrgb => "Linear sRGB (PhotoCraft)",
|
||||
Builtin::AcesCg => "ACEScg (AP1, linear, PhotoCraft)",
|
||||
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (PhotoCraft)",
|
||||
Builtin::GrayGamma22 => "Gray Gamma 2.2 (PhotoCraft)",
|
||||
Builtin::SGray => "sGray (sRGB tone curve, PhotoCraft)",
|
||||
Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)",
|
||||
Builtin::LabD50 => "Lab D50 identity (PhotoCraft)",
|
||||
Builtin::CoatedCmyk => crate::synth::DESCRIPTION,
|
||||
Builtin::CoatedFogra39 => COATED_FOGRA39_DESCRIPTION,
|
||||
}
|
||||
@@ -111,6 +123,8 @@ impl Builtin {
|
||||
"rec2020" | "bt2020" => Some(Builtin::Rec2020),
|
||||
"gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22),
|
||||
"sgray" | "graysrgb" => Some(Builtin::SGray),
|
||||
"dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat),
|
||||
"aces-cg" | "acescg" => Some(Builtin::AcesCg),
|
||||
"lab" | "labd50" | "cielab" => Some(Builtin::LabD50),
|
||||
"cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk),
|
||||
"fogra39" | "coatedfogra39" => Some(Builtin::CoatedFogra39),
|
||||
@@ -151,7 +165,7 @@ impl Builtin {
|
||||
|
||||
/// The profile (built once per process).
|
||||
pub fn profile(self) -> &'static Profile {
|
||||
static CELLS: [OnceLock<Profile>; 11] = [const { OnceLock::new() }; 11];
|
||||
static CELLS: [OnceLock<Profile>; 13] = [const { OnceLock::new() }; 13];
|
||||
// `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))
|
||||
@@ -163,11 +177,12 @@ fn profile_name_key(s: &str) -> String {
|
||||
s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// The default profile for a colour space: sRGB, sGray, the coated CMYK profile, Lab D50.
|
||||
/// The default profile for a colour space: sRGB, Dot Gain 20 %-compatible gray (Photoshop's default
|
||||
/// working gray), 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::Gray => Builtin::DotGain20Compat.profile(),
|
||||
ColorSpace::Cmyk => Builtin::CoatedCmyk.profile(),
|
||||
ColorSpace::Lab => Builtin::LabD50.profile(),
|
||||
_ => return None,
|
||||
@@ -182,7 +197,7 @@ pub fn srgb() -> &'static Profile {
|
||||
/// 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)))
|
||||
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).
|
||||
@@ -211,6 +226,8 @@ const P3_SPACE: RgbSpace = rgb_space!([[0.680, 0.320], [0.265, 0.690], [0.150, 0
|
||||
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);
|
||||
/// ACEScg: the AP1 primaries with the ACES white point (~D60, xy 0.32168/0.33767, the ACES 2065-1 neutral).
|
||||
const AP1_SPACE: RgbSpace = rgb_space!([[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]], math::xy_to_xyz([0.32168, 0.33767]));
|
||||
|
||||
/// 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).
|
||||
@@ -321,6 +338,46 @@ pub const COATED_FOGRA39_ICC: &[u8] = include_bytes!("../profiles/craft-coated-f
|
||||
/// 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");
|
||||
|
||||
/// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
|
||||
fn rec709_trc() -> Curve {
|
||||
let a = 1.099;
|
||||
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] }
|
||||
}
|
||||
|
||||
/// The RGB profile for an ITU-T H.273 (CICP) colour description, as a HEIF `nclx` or a video
|
||||
/// stream declares it: BT.709, BT.2020 or P3-D65 primaries (codes 1, 9, 12) with the sRGB,
|
||||
/// BT.709, linear or gamma 2.2/2.8 transfer (13; 1, 6, 14, 15; 8; 4; 5). `linear` means the
|
||||
/// samples were already decoded to linear light, which is how PQ and HLG (16, 18) images are
|
||||
/// held, since no ICC curve represents those transfers. `None` for anything else.
|
||||
pub fn cicp_rgb(primaries: u16, transfer: u16, linear: bool) -> Option<Profile> {
|
||||
let (prim, name) = match primaries {
|
||||
1 => (SRGB_SPACE, "BT.709"),
|
||||
9 => (BT2020_SPACE, "Rec. ITU-R BT.2020"),
|
||||
12 => (P3_SPACE, "Display P3"),
|
||||
_ => return None,
|
||||
};
|
||||
let transfer = if linear { 8 } else { transfer };
|
||||
let builtin = match (primaries, transfer) {
|
||||
(1, 13) => Some(Builtin::Srgb),
|
||||
(1, 8) => Some(Builtin::LinearSrgb),
|
||||
(12, 13) => Some(Builtin::DisplayP3),
|
||||
(9, 1 | 6 | 14 | 15) => Some(Builtin::Rec2020),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(b) = builtin {
|
||||
return Some(b.profile().clone());
|
||||
}
|
||||
let (trc, curve) = match transfer {
|
||||
8 => (Curve::Gamma(1.0), "linear"),
|
||||
13 => (srgb_trc(), "sRGB curve"),
|
||||
1 | 6 | 14 | 15 => (rec709_trc(), "BT.709 curve"),
|
||||
4 => (Curve::Gamma(2.2), "gamma 2.2"),
|
||||
5 => (Curve::Gamma(2.8), "gamma 2.8"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(rgb(&format!("{name} {curve} (PhotoCraft)"), prim, trc))
|
||||
}
|
||||
|
||||
fn build(b: Builtin) -> Profile {
|
||||
match b {
|
||||
Builtin::Srgb => rgb(b.description(), SRGB_SPACE, srgb_trc()),
|
||||
@@ -328,14 +385,11 @@ fn build(b: Builtin) -> Profile {
|
||||
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::AcesCg => rgb(b.description(), AP1_SPACE, Curve::Gamma(1.0)),
|
||||
Builtin::Rec2020 => rgb(b.description(), BT2020_SPACE, rec709_trc()),
|
||||
Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)),
|
||||
Builtin::SGray => gray(b.description(), srgb_trc()),
|
||||
Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)),
|
||||
Builtin::LabD50 => lab(),
|
||||
// The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test).
|
||||
Builtin::CoatedCmyk => shipped_cmyk(COATED_CMYK_ICC, b),
|
||||
@@ -369,3 +423,30 @@ fn shipped_cmyk(bytes: &[u8], b: Builtin) -> Profile {
|
||||
hash: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cicp_maps_onto_the_builtins_and_linear_bt2020() {
|
||||
let desc = |p: Option<Profile>| p.map(|p| p.description);
|
||||
assert_eq!(desc(cicp_rgb(1, 13, false)), Some(Builtin::Srgb.description().to_string()));
|
||||
assert_eq!(desc(cicp_rgb(12, 13, false)), Some(Builtin::DisplayP3.description().to_string()));
|
||||
assert_eq!(desc(cicp_rgb(9, 14, false)), Some(Builtin::Rec2020.description().to_string()));
|
||||
assert_eq!(desc(cicp_rgb(1, 18, true)), Some(Builtin::LinearSrgb.description().to_string()));
|
||||
|
||||
// An HLG BT.2020 image decoded to linear light: BT.2020's colorants, a linear curve.
|
||||
let hlg = cicp_rgb(9, 18, true).unwrap();
|
||||
assert_eq!(hlg.trc, Some([Curve::Gamma(1.0), Curve::Gamma(1.0), Curve::Gamma(1.0)]));
|
||||
assert_eq!(hlg.matrix, Builtin::Rec2020.profile().matrix);
|
||||
assert!(hlg.description.contains("BT.2020") && hlg.description.contains("linear"), "{}", hlg.description);
|
||||
assert!(Profile::parse(&hlg.to_bytes()).is_ok(), "encodes as a valid ICC profile");
|
||||
|
||||
// HLG and PQ have no ICC curve; unknown primaries have no profile.
|
||||
assert!(cicp_rgb(9, 18, false).is_none());
|
||||
assert!(cicp_rgb(9, 16, false).is_none());
|
||||
assert!(cicp_rgb(2, 13, false).is_none());
|
||||
assert!(cicp_rgb(u16::MAX, u16::MAX, true).is_none());
|
||||
}
|
||||
}
|
||||
@@ -350,4 +350,18 @@ mod tests {
|
||||
narrow.eval_multilinear(&[0.3, 0.7], &mut b).unwrap();
|
||||
assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_dimensions_do_not_change_the_result() {
|
||||
// Axes 1 and 3 have a single grid point (degenerate); axes 0, 2, 4 vary. Interpolating
|
||||
// over the effective dimensions only gives the same answer as the function sampled.
|
||||
let f = |p: &[f32], o: &mut [f32]| o[0] = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4];
|
||||
let c = Clut::sample(vec![3, 1, 4, 1, 5], 1, f).unwrap();
|
||||
let mut got = [0.0];
|
||||
for p in [[0.2f32, 0.0, 0.7, 0.0, 0.4], [1.0, 0.0, 0.0, 0.0, 1.0], [0.5, 0.0, 0.5, 0.0, 0.5]] {
|
||||
c.eval_multilinear(&p, &mut got).unwrap();
|
||||
let want = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4];
|
||||
assert!((got[0] - want).abs() < 1e-5, "got {} want {want}", got[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -305,15 +305,22 @@ fn table_inverse_with(t: &[f32], y: f32, edge: impl Fn(bool) -> f32) -> 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.
|
||||
///
|
||||
/// A table that is inverted is monotonic, so the run is found by bisection, not by a scan: a long
|
||||
/// constant table is inverted once per pixel, and scanning it each time made the cost the curve's
|
||||
/// length times the image's.
|
||||
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);
|
||||
// The first value that differs, given they do so from there on.
|
||||
let first_other = t.partition_point(|x| (*x - v).abs() <= 1e-7);
|
||||
let i = if first_other >= n { n - 1 } else { first_other.saturating_sub(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);
|
||||
// The start of the trailing run equal to the last value.
|
||||
let i = t.partition_point(|x| (*x - v).abs() > 1e-7);
|
||||
i as f32 / (n - 1) as f32
|
||||
}
|
||||
}
|
||||
@@ -390,4 +397,19 @@ mod tests {
|
||||
let g = Curve::Gamma(2.2);
|
||||
assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverting_a_long_flat_table_does_not_rescan_it_for_every_pixel() {
|
||||
// Four million zeros and a one: a scan per call would be 8·10¹¹ steps for these calls.
|
||||
let mut t = vec![0.0f32; 4_000_000];
|
||||
t.push(1.0);
|
||||
let c = Curve::Table(t);
|
||||
let started = std::time::Instant::now();
|
||||
let mut last = 0.0;
|
||||
for _ in 0..200_000 {
|
||||
last = c.eval_inverse(0.0);
|
||||
}
|
||||
assert!(last > 0.99, "the flat start ends at the last zero: {last}");
|
||||
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Separable RGB display transforms without a coarse 3D interpolation near black.
|
||||
|
||||
/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display
|
||||
/// transform. Values are floats independent of the document's pixel depth.
|
||||
///
|
||||
/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply
|
||||
/// [`Self::matrix_rows`] to that result, then sample the output table at
|
||||
/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel
|
||||
/// separately between its two adjacent table entries. These nonuniform output samples
|
||||
/// retain shadow detail where a pure gamma curve's inverse has an infinite slope.
|
||||
///
|
||||
/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels.
|
||||
/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable.
|
||||
/// This representation covers SDR display output; input and output are clamped to `[0, 1]`.
|
||||
/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the
|
||||
/// [`crate::Lut3d`] fallback.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GpuMatrixShaper {
|
||||
matrix: [[f32; 4]; 3],
|
||||
input: Vec<[f32; 4]>,
|
||||
output: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl GpuMatrixShaper {
|
||||
/// The input table has one more entry than this number of equal intervals.
|
||||
pub const INPUT_INTERVALS: usize = 4096;
|
||||
/// Output entries are spaced uniformly in the fourth root of the linear value.
|
||||
pub const OUTPUT_ENTRIES: usize = 4096;
|
||||
|
||||
/// Row-major matrix: each row contains three RGB coefficients followed by its offset.
|
||||
pub fn matrix_rows(&self) -> [[f32; 4]; 3] {
|
||||
self.matrix
|
||||
}
|
||||
|
||||
/// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1.
|
||||
pub fn input_table(&self) -> &[[f32; 4]] {
|
||||
&self.input
|
||||
}
|
||||
|
||||
/// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1.
|
||||
pub fn output_table(&self) -> &[[f32; 4]] {
|
||||
&self.output
|
||||
}
|
||||
|
||||
pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec<f32>], output: Option<&[Vec<f32>]>) -> Option<Self> {
|
||||
if !matrix.iter().flatten().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?;
|
||||
let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?;
|
||||
let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max));
|
||||
for row in matrix {
|
||||
let magnitude = row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::<f64>()
|
||||
+ f64::from(row[3]).abs();
|
||||
if magnitude > f64::from(f32::MAX) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self { matrix, input, output })
|
||||
}
|
||||
}
|
||||
|
||||
fn pack_table(tables: Option<&[Vec<f32>]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option<Vec<[f32; 4]>> {
|
||||
if let Some(tables) = tables
|
||||
&& (tables.len() != 3 || tables.iter().any(|t| t.len() != entries))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut packed: Vec<[f32; 4]> = Vec::new();
|
||||
// Both lengths are fixed constants, but a failed allocation still means the caller
|
||||
// should retain its existing fallback rather than abort while opening a document.
|
||||
packed.try_reserve_exact(entries).ok()?;
|
||||
for i in 0..entries {
|
||||
let rgb = match tables {
|
||||
Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?],
|
||||
None => [identity(i as f32 / (entries - 1) as f32); 3],
|
||||
};
|
||||
if !rgb.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(previous) = packed.last()
|
||||
&& rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
packed.push([rgb[0], rgb[1], rgb[2], 1.0]);
|
||||
}
|
||||
Some(packed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_tables_and_nonfinite_values() {
|
||||
let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
|
||||
let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3];
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none());
|
||||
let mut invalid = inputs.clone();
|
||||
invalid[0].pop();
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
|
||||
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
let mut invalid = inputs.clone();
|
||||
invalid[1][123] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
|
||||
let mut invalid = outputs.clone();
|
||||
invalid[2][321] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none());
|
||||
let mut invalid = IDENTITY;
|
||||
invalid[1][3] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_finite_data_that_overflows_shader_arithmetic() {
|
||||
let mut matrix = IDENTITY;
|
||||
matrix[0] = [f32::MAX; 4];
|
||||
assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none());
|
||||
let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
|
||||
inputs[0][100] = -f32::MAX;
|
||||
inputs[0][101] = f32::MAX;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
//! * [`NamedColors`] reads a named-colour profile's spot colours and converts them into a
|
||||
//! destination profile.
|
||||
//! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas.
|
||||
//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms.
|
||||
//!
|
||||
//! 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.
|
||||
@@ -22,6 +23,7 @@ pub mod builtin;
|
||||
pub mod clut;
|
||||
pub mod curve;
|
||||
pub mod gamut;
|
||||
mod gpu_matrix_shaper;
|
||||
pub mod lut3d;
|
||||
pub mod lutfile;
|
||||
pub mod math;
|
||||
@@ -36,6 +38,7 @@ pub use builtin::Builtin;
|
||||
pub use clut::Clut;
|
||||
pub use curve::Curve;
|
||||
pub use gamut::GamutCheck;
|
||||
pub use gpu_matrix_shaper::GpuMatrixShaper;
|
||||
pub use lut3d::Lut3d;
|
||||
pub use named::{NamedColor, NamedColors};
|
||||
pub use profile::{ColorSpace, Pcs, Profile, ProfileClass};
|
||||
|
||||
@@ -37,6 +37,25 @@ impl Lut3d {
|
||||
Ok(Lut3d { size, data })
|
||||
}
|
||||
|
||||
/// Samples `f` (RGB in, RGB out) on a `size`³ grid, `size` from 2 to [`MAX_SIZE`]: a display
|
||||
/// chain that is more than one [`Transform`].
|
||||
pub fn from_fn(size: usize, mut f: impl FnMut([f32; 3]) -> [f32; 3]) -> Result<Lut3d, CmsError> {
|
||||
if !(2..=MAX_SIZE).contains(&size) {
|
||||
return Err(invalid_arg(format!("3D LUT size {size} (2 to {MAX_SIZE})")));
|
||||
}
|
||||
let s = (size - 1) as f32;
|
||||
let mut data = Vec::with_capacity(size * size * size);
|
||||
for b in 0..size {
|
||||
for g in 0..size {
|
||||
for r in 0..size {
|
||||
let v = f([r as f32 / s, g as f32 / s, b as f32 / s]);
|
||||
data.push([v[0], v[1], v[2], 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Lut3d { size, data })
|
||||
}
|
||||
|
||||
/// Edge length.
|
||||
pub fn size(&self) -> usize {
|
||||
self.size
|
||||
@@ -156,4 +175,17 @@ mod tests {
|
||||
assert_eq!(f32_to_f16(1.0), 0x3C00);
|
||||
assert_eq!(f32_to_f16(1e6), 0x7C00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_fn_samples_red_fastest_and_bounds_the_size() {
|
||||
let lut = Lut3d::from_fn(3, |[r, g, b]| [r, g * 0.5, 1.0 - b]).unwrap();
|
||||
assert_eq!(lut.size(), 3);
|
||||
assert_eq!(lut.data().len(), 27);
|
||||
assert_eq!(lut.data()[1], [0.5, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(lut.data()[3], [0.0, 0.25, 1.0, 1.0]);
|
||||
assert_eq!(lut.data()[26], [1.0, 0.5, 0.0, 1.0]);
|
||||
for size in [0, 1, MAX_SIZE + 1, usize::MAX] {
|
||||
assert!(matches!(Lut3d::from_fn(size, |v| v), Err(CmsError::InvalidArgument(_))), "{size}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+715
-23
@@ -88,16 +88,63 @@ impl LutFile {
|
||||
}
|
||||
}
|
||||
|
||||
/// A LUT file format.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
Cube,
|
||||
ThreeDl,
|
||||
Look,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// The format [`parse`] reads `bytes` as: from `file_name`'s extension, else sniffed
|
||||
/// (XML is `.look`, anything else `.cube`).
|
||||
pub fn detect(file_name: &str, bytes: &[u8]) -> Self {
|
||||
let lower = file_name.to_ascii_lowercase();
|
||||
if lower.ends_with(".3dl") {
|
||||
Format::ThreeDl
|
||||
} else if lower.ends_with(".look") || decode_text(bytes).trim_start().starts_with('<') {
|
||||
Format::Look
|
||||
} else {
|
||||
Format::Cube
|
||||
}
|
||||
}
|
||||
|
||||
/// The file extension, without the dot.
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Format::Cube => "cube",
|
||||
Format::ThreeDl => "3dl",
|
||||
Format::Look => "look",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
parse_as(Format::detect(file_name, bytes), bytes)
|
||||
}
|
||||
|
||||
/// Parses a LUT in a known format.
|
||||
pub fn parse_as(format: Format, bytes: &[u8]) -> Result<LutFile, LutError> {
|
||||
let text = decode_text(bytes);
|
||||
match format {
|
||||
Format::Cube => parse_cube(&text),
|
||||
Format::ThreeDl => parse_3dl(&text),
|
||||
Format::Look => parse_look(&text),
|
||||
}
|
||||
}
|
||||
|
||||
/// UTF-16 with a byte-order mark (Photoshop loads such `.look` files, #9118), else UTF-8.
|
||||
fn decode_text(bytes: &[u8]) -> std::borrow::Cow<'_, str> {
|
||||
let utf16 = |rest: &[u8], from: fn([u8; 2]) -> u16| {
|
||||
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|&c| from(c)).collect();
|
||||
String::from_utf16_lossy(&units).into()
|
||||
};
|
||||
match bytes {
|
||||
[0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes),
|
||||
[0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes),
|
||||
_ => String::from_utf8_lossy(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +155,14 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
|
||||
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();
|
||||
// A '#' inside a quoted TITLE is part of the title, not a comment.
|
||||
let raw = line.trim_start();
|
||||
let skip = if raw.starts_with("TITLE") { raw.find('"').and_then(|q| raw[q + 1..].find('"').map(|e| q + e + 2)) } else { None }.unwrap_or(0);
|
||||
let line = match raw[skip..].find('#') {
|
||||
Some(i) => &raw[..skip + i],
|
||||
None => raw,
|
||||
}
|
||||
.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -172,41 +226,226 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
|
||||
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.
|
||||
/// Lustre `.3dl`: an optional `3DMESH` magic and `Mesh <inBits> <outBits>` line, an optional
|
||||
/// shaper line of input values, then integer RGB rows with **blue** varying fastest. The
|
||||
/// `Mesh` line declares the cube edge (2^inBits + 1) and the output code range (up to
|
||||
/// 2^outBits − 1), and Photoshop honours both (#9073, #9078); without it the edge is the cube
|
||||
/// root of the row count and the range is inferred from the largest value (#9121). A shaper that
|
||||
/// isn't the even ramp is applied before the table, as Photoshop does (#8982): see [`apply_shaper`].
|
||||
pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
|
||||
let mut mesh: Option<(u32, u32)> = None;
|
||||
let mut rows: Vec<[f64; 3]> = Vec::new();
|
||||
// How many numbers the first numeric line carried: a shaper of exactly `n` values (n ≤ 3)
|
||||
// is otherwise indistinguishable from a node row.
|
||||
let mut first_len: Option<usize> = None;
|
||||
// Any text line before the rows is a header: a headerless table peaking at 255 or 1023 is
|
||||
// on the 12-bit scale, as Photoshop 27.11 reads it (#9121).
|
||||
let mut header = false;
|
||||
let mut shaper: Option<Vec<f64>> = None;
|
||||
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()) {
|
||||
// A header is `3DMESH` or starts with a word (`Mesh 4 12`): a letter, and not a number.
|
||||
// `NaN` and `inf` are numbers, and `0oops` starts like one: those rows are read strictly.
|
||||
let Some(first) = line.split_whitespace().next() else { continue };
|
||||
if first.eq_ignore_ascii_case("3DMESH") || first.starts_with(char::is_alphabetic) && first.parse::<f64>().is_err() {
|
||||
header = true;
|
||||
// The only header keyword that carries data. A malformed or out-of-range `Mesh`
|
||||
// line is ignored, as every text line always was.
|
||||
let mut it = line.split_whitespace();
|
||||
if it.next().is_some_and(|k| k.eq_ignore_ascii_case("Mesh")) {
|
||||
// A declaration is exactly two integer fields after the keyword. Anything else
|
||||
// (a non-numeric field, too few or too many) is not a declaration: `mesh` stays
|
||||
// unset so headerless inference applies, rather than recovering numbers from
|
||||
// the wrong positions (#9078).
|
||||
let fields: Vec<&str> = it.collect();
|
||||
if let [a, b] = fields.as_slice()
|
||||
&& let (Ok(n), Ok(bits)) = (a.parse::<u32>(), b.parse::<u32>())
|
||||
&& (1..=7).contains(&n)
|
||||
&& (1..=16).contains(&bits)
|
||||
{
|
||||
mesh = Some((n, bits));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let v: Vec<f64> = line.split_whitespace().filter_map(|s| s.parse().ok()).collect();
|
||||
// Every token is a number: a malformed one is refused rather than dropped, which could
|
||||
// turn a shaper into an even ramp and lose it silently.
|
||||
let v: Vec<f64> = line.split_whitespace().map(str::parse).collect::<Result<_, _>>().map_err(|_| LutError(format!("bad line `{line}`")))?;
|
||||
first_len.get_or_insert(v.len());
|
||||
if v.len() == 3 {
|
||||
rows.push([v[0], v[1], v[2]]);
|
||||
} else if v.len() >= 2 {
|
||||
// Two values are a 2-point shaper (a 2³ table's), not something to drop.
|
||||
// The input shaper: one row of values ahead of the table, and only one.
|
||||
if shaper.is_some() || !rows.is_empty() {
|
||||
return err(format!("a second shaper row `{line}`"));
|
||||
}
|
||||
shaper = Some(v);
|
||||
}
|
||||
// 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 (n, nodes, scale) = match mesh {
|
||||
Some((in_bits, out_bits)) => {
|
||||
let n = 2usize.pow(in_bits) + 1;
|
||||
let expected = n.checked_pow(3).ok_or_else(|| LutError(format!("unsupported 3dl cube size {n}")))?;
|
||||
// One row more than the declared cube, and the first row has exactly `n` values:
|
||||
// that row is the shaper, not a 28th node of a 3³ cube (#9078).
|
||||
let nodes = if shaper.is_none() && rows.len() == expected + 1 && first_len == Some(n) {
|
||||
shaper = rows.first().map(|row| row.to_vec());
|
||||
&rows[1..]
|
||||
} else {
|
||||
&rows[..]
|
||||
};
|
||||
if nodes.len() != expected {
|
||||
return err(format!("expected {expected} rows for the declared {n}³ cube, found {}", nodes.len()));
|
||||
}
|
||||
// Under a valid declaration the node codes are integers in the declared range, so
|
||||
// the declaration wins for every in-range value — including a small max, where 1 is
|
||||
// 1/declared of full output, not full output itself (#9073). Only a node beyond the
|
||||
// declared range is a wrong declaration, and the old inference keeps such a file
|
||||
// loading as before.
|
||||
let max = nodes.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
|
||||
let declared = 2f64.powi(i32::try_from(out_bits).unwrap_or(16)) - 1.0;
|
||||
(n, nodes, if max > declared { inferred_3dl_scale(max, header) } else { declared })
|
||||
}
|
||||
None => {
|
||||
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));
|
||||
(n, &rows[..], inferred_3dl_scale(max, header))
|
||||
}
|
||||
};
|
||||
let mut data = vec![0.0f32; n * n * n * 3];
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
for (i, row) in nodes.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()
|
||||
let lut = LutFile { title: String::new(), size: n, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check()?;
|
||||
Ok(match shaper {
|
||||
Some(shaper) => apply_shaper(lut, &shaper)?,
|
||||
None => lut,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bakes a `.3dl` shaper into the table. Photoshop applies the shaper row as a 1-D curve on each
|
||||
/// input channel ahead of the 3-D table (input `x` reads the shaper, sampled evenly over 0..1,
|
||||
/// at `x`, interpolated linearly), so each node of the table becomes the table's own trilinear
|
||||
/// value at the shaped input. An even ramp (the usual shaper) changes nothing and is skipped.
|
||||
/// The table is baked on a grid holding both its own nodes and the shaper's knots: exact for an
|
||||
/// identity table, an approximation at that resolution otherwise. A shaper that would need more
|
||||
/// than [`MAX_SIZE`] nodes for that, or that holds a value that isn't finite, is refused.
|
||||
fn apply_shaper(lut: LutFile, shaper: &[f64]) -> Result<LutFile, LutError> {
|
||||
let m = shaper.len();
|
||||
if m < 2 {
|
||||
return Ok(lut);
|
||||
}
|
||||
// Apply or refuse: a shaper that can't be applied never leaves the table to render alone.
|
||||
if shaper.iter().any(|v| !v.is_finite()) {
|
||||
return err("the .3dl shaper holds a value that isn't finite");
|
||||
}
|
||||
// The shaper's own depth, from 10 bits up: a 0..1023 ramp is the even ramp with or without a header.
|
||||
let scale = inferred_3dl_scale(shaper.iter().fold(0.0f64, |a, &b| a.max(b)), true);
|
||||
let even = |k: usize| scale * k as f64 / (m - 1) as f64;
|
||||
// Shapers round their ramp: within one code is still the even ramp. An integer shaper's code
|
||||
// is 1; a float shaper (0..1) is held to one 10-bit step.
|
||||
let code = if scale > 1.0 { 1.0 } else { 1.0 / 1023.0 };
|
||||
if shaper.iter().enumerate().all(|(k, &v)| (v - even(k)).abs() <= code) {
|
||||
return Ok(lut);
|
||||
}
|
||||
// Scaled to 0..1 first, so interpolating between two extreme values can't overflow.
|
||||
let shaped: Vec<f64> = shaper.iter().map(|v| v / scale).collect();
|
||||
let curve = |x: f64| -> f32 {
|
||||
let p = x.clamp(0.0, 1.0) * (m - 1) as f64;
|
||||
let i = (p.floor() as usize).min(m - 2);
|
||||
let f = p - i as f64;
|
||||
let (a, b) = (shaped.get(i).copied().unwrap_or(0.0), shaped.get(i + 1).copied().unwrap_or(0.0));
|
||||
(a + (b - a) * f).clamp(0.0, 1.0) as f32
|
||||
};
|
||||
// The bake grid holds both the table's nodes and the shaper's knots (lcm of their intervals),
|
||||
// so the curve keeps its breakpoints whatever the table's size: exact on an identity table. A
|
||||
// grid that would pass MAX_SIZE is refused: sampled more coarsely, a bent shaper could read as
|
||||
// even and be lost without a word.
|
||||
let gcd = |mut a: usize, mut b: usize| {
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
a
|
||||
};
|
||||
let (a, k) = (lut.size - 1, m - 1);
|
||||
let aligned = (a / gcd(a, k)).checked_mul(k).map(|l| l + 1).filter(|&l| l <= MAX_SIZE);
|
||||
let Some(n) = aligned else {
|
||||
return err(format!("the .3dl shaper's {m} points can't be kept on a table of at most {MAX_SIZE} nodes"));
|
||||
};
|
||||
let step = (n - 1) as f64;
|
||||
let mut out = LutFile { size: n, data: vec![0.0; n * n * n * 3], ..lut.clone() };
|
||||
for b in 0..n {
|
||||
for g in 0..n {
|
||||
for r in 0..n {
|
||||
let at = ((b * n + g) * n + r) * 3;
|
||||
let v = sample(&lut, [curve(r as f64 / step), curve(g as f64 / step), curve(b as f64 / step)]);
|
||||
if let Some(dst) = out.data.get_mut(at..at + 3) {
|
||||
dst.copy_from_slice(&v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.check()
|
||||
}
|
||||
|
||||
/// Trilinear lookup of `rgb` (0..1) in a [`LutFile`] (red fastest).
|
||||
fn sample(l: &LutFile, rgb: [f32; 3]) -> [f32; 3] {
|
||||
let n = l.size;
|
||||
let loc = |v: f32| {
|
||||
let p = v.clamp(0.0, 1.0) * (n - 1) as f32;
|
||||
let i = (p as usize).min(n.saturating_sub(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, k: usize| l.data.get(((b * n + g) * n + r) * 3 + k).copied().unwrap_or(0.0);
|
||||
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
|
||||
}
|
||||
|
||||
/// The code range a `.3dl` node table without a declared output depth was written with: the
|
||||
/// smallest common integer depth that covers the largest value; a table of 0..1 floats passes
|
||||
/// through. A headerless table has no 10-bit reading — 255 and 1023 are on the 12-bit scale, as
|
||||
/// Photoshop 27.11 reads them (#9121).
|
||||
fn inferred_3dl_scale(max: f64, header: bool) -> f64 {
|
||||
if max <= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] };
|
||||
depths.iter().copied().find(|&s| max <= s).unwrap_or(max)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
// Comments may hold tag-like text, so drop them before looking for tags (#9109).
|
||||
let mut clean = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(i) = rest.find("<!--") {
|
||||
clean.push_str(&rest[..i]);
|
||||
rest = rest[i + 4..].find("-->").map_or("", |e| &rest[i + 4 + e + 3..]);
|
||||
}
|
||||
clean.push_str(rest);
|
||||
let text = clean.as_str();
|
||||
let tag = |name: &str| -> Option<String> {
|
||||
let open = format!("<{name}>");
|
||||
let start = text.find(&open)? + open.len();
|
||||
@@ -215,7 +454,14 @@ pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
|
||||
};
|
||||
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 hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
|
||||
if hex.iter().any(|b| !b.is_ascii_hexdigit()) {
|
||||
return err("invalid hexadecimal <data> in .look");
|
||||
}
|
||||
// Each float needs eight hex digits; as_chunks would silently discard a partial value.
|
||||
if !hex.len().is_multiple_of(8) {
|
||||
return err("incomplete float in .look <data>");
|
||||
}
|
||||
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();
|
||||
@@ -308,6 +554,226 @@ pub fn builtin(id: &str) -> Option<LutFile> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An identity `.3dl` of edge `n` (12-bit nodes, blue fastest) under `shaper`.
|
||||
fn identity_3dl(shaper: &[u32], n: usize) -> String {
|
||||
let mut t = shaper.iter().map(u32::to_string).collect::<Vec<_>>().join(" ") + "\n";
|
||||
let node = |i: usize| (i * 4095 / (n - 1)).to_string();
|
||||
for r in 0..n {
|
||||
for g in 0..n {
|
||||
for b in 0..n {
|
||||
t += &format!("{} {} {}\n", node(r), node(g), node(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_dl_shaper_applies_before_the_table() {
|
||||
// #8982: Photoshop applies a .3dl's shaper row as a curve ahead of the 3-D table. On grey
|
||||
// 128 an identity table with the shaper 0 114 455 1023 gives 71 there, and a 17-point
|
||||
// squared shaper 64 (reported measurements); even ramps leave grey 128 as it is.
|
||||
let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round();
|
||||
let bent = parse_3dl(&identity_3dl(&[0, 114, 455, 1023], 4)).unwrap();
|
||||
assert_eq!(grey(&bent), 71.0);
|
||||
let squared: Vec<u32> = (0..17).map(|i| (1023.0 * (i as f64 / 16.0).powi(2)).round() as u32).collect();
|
||||
assert_eq!(grey(&parse_3dl(&identity_3dl(&squared, 17)).unwrap()), 64.0);
|
||||
let even4 = parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4)).unwrap();
|
||||
assert_eq!(grey(&even4), 128.0);
|
||||
assert_eq!(even4, parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4).lines().skip(1).collect::<Vec<_>>().join("\n")).unwrap());
|
||||
let even17: Vec<u32> = (0..17).map(|i| (1023.0 * i as f64 / 16.0).round() as u32).collect();
|
||||
assert_eq!(grey(&parse_3dl(&identity_3dl(&even17, 17)).unwrap()), 128.0);
|
||||
// The ends stay put, and the shaper bends each channel on its own.
|
||||
assert_eq!(sample(&bent, [0.0, 1.0, 0.0]), [0.0, 1.0, 0.0]);
|
||||
let c = sample(&bent, [1.0 / 3.0, 0.0, 2.0 / 3.0]);
|
||||
assert!((c[0] - 114.0 / 1023.0).abs() < 1e-4 && (c[2] - 455.0 / 1023.0).abs() < 1e-4, "{c:?}");
|
||||
// A shaper that isn't finite is refused rather than dropped (see
|
||||
// `three_dl_non_finite_shaper_is_refused`).
|
||||
assert!(parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4).replacen("341", "NaN", 1)).is_err());
|
||||
}
|
||||
|
||||
/// #9170 review: a float shaper (values in 0..1) is held to a float ramp, not to one integer
|
||||
/// code, so a bent float shaper is applied.
|
||||
#[test]
|
||||
fn three_dl_float_shaper_is_applied() {
|
||||
let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round();
|
||||
let with = |shaper: &str| {
|
||||
let table = identity_3dl(&[0, 1, 2, 3], 4);
|
||||
parse_3dl(&format!("{shaper}\n{}", table.split_once('\n').unwrap().1)).unwrap()
|
||||
};
|
||||
// 0.502 lands between 0.1 and 0.3 at 0.506 of the way: 0.2012, so grey 51.
|
||||
assert_eq!(grey(&with("0 0.1 0.3 1.0")), 51.0);
|
||||
// An even float ramp, written to three decimals, still changes nothing.
|
||||
assert_eq!(grey(&with("0 0.333 0.667 1.0")), 128.0);
|
||||
}
|
||||
|
||||
/// #9170 review: a shaper with more points than the table keeps its curve: the table is
|
||||
/// resampled at the shaper's resolution rather than reading the shaper at its own two nodes.
|
||||
#[test]
|
||||
fn three_dl_shaper_finer_than_the_table_keeps_its_curve() {
|
||||
let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round();
|
||||
let small = parse_3dl(&identity_3dl(&[0, 0, 512, 1023], 2)).unwrap();
|
||||
// 0.502 lands between 0 and 512 at 0.506 of the way: 259 / 1023, so grey 65.
|
||||
assert_eq!(grey(&small), 65.0);
|
||||
assert_eq!(sample(&small, [1.0, 0.0, 1.0]), [1.0, 0.0, 1.0], "the ends stay put");
|
||||
}
|
||||
|
||||
/// #9170 review: a finite but extreme shaper can't overflow into NaN when it is baked.
|
||||
#[test]
|
||||
fn three_dl_extreme_shaper_bakes_only_finite_values() {
|
||||
let table = identity_3dl(&[0, 1, 2, 3], 2);
|
||||
let text = format!("0 1e308 -1e308 0\n{}", table.split_once('\n').unwrap().1);
|
||||
// Refused or baked, but never a table holding NaN.
|
||||
if let Ok(l) = parse_3dl(&text) {
|
||||
assert!(l.data.iter().all(|v| v.is_finite()), "baked a non-finite value");
|
||||
}
|
||||
}
|
||||
|
||||
/// #9170 review (bommerts): the shaper's breakpoints survive a table whose grid doesn't line
|
||||
/// up with them. The bake grid holds both the table's nodes and the shaper's knots, so on an
|
||||
/// identity table the curve is exact: `0 0 512 1023` is 0 at ⅓ over 5³ and 17³ tables.
|
||||
#[test]
|
||||
fn three_dl_shaper_knots_survive_a_nonaligned_table() {
|
||||
for n in [5, 17] {
|
||||
let l = parse_3dl(&identity_3dl(&[0, 0, 512, 1023], n)).unwrap();
|
||||
let at = |x: f32| sample(&l, [x, 0.0, 0.0])[0];
|
||||
assert!(at(1.0 / 3.0).abs() < 1e-6, "{n}³: {}", at(1.0 / 3.0));
|
||||
// The helper's 12-bit nodes are truncated, so the table itself is within a 12-bit step.
|
||||
assert!((at(2.0 / 3.0) - 512.0 / 1023.0).abs() < 1.0 / 4095.0, "{n}³: {}", at(2.0 / 3.0));
|
||||
assert!((at(0.5) - 256.0 / 1023.0).abs() < 1.0 / 4095.0, "{n}³: halfway between the knots");
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-value shaper row (a 2³ table's) is applied like a longer one, not dropped; the even
|
||||
/// two-point ramp changes nothing.
|
||||
#[test]
|
||||
fn three_dl_two_point_shaper_is_applied() {
|
||||
let bent = parse_3dl(&identity_3dl(&[0, 512], 2)).unwrap();
|
||||
let top = sample(&bent, [1.0, 1.0, 1.0]);
|
||||
assert!(top.iter().all(|v| (v - 512.0 / 1023.0).abs() < 1e-4), "{top:?}");
|
||||
assert!((sample(&bent, [0.5, 0.0, 0.0])[0] - 256.0 / 1023.0).abs() < 1e-4);
|
||||
let even = parse_3dl(&identity_3dl(&[0, 1023], 2)).unwrap();
|
||||
assert_eq!(
|
||||
even,
|
||||
parse_3dl(&identity_3dl(&[0, 1023], 2).lines().skip(1).collect::<Vec<_>>().join(
|
||||
"
|
||||
"
|
||||
))
|
||||
.unwrap()
|
||||
);
|
||||
// A second two-value row is a second shaper, refused like a longer one.
|
||||
let body = identity_3dl(&[0, 512], 2);
|
||||
assert!(
|
||||
parse_3dl(&format!(
|
||||
"{body}0 512
|
||||
"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// #9170 review (bommerts): a shaper row with a token that isn't a number, or a second
|
||||
/// shaper row, is refused rather than silently dropped.
|
||||
#[test]
|
||||
fn three_dl_malformed_shaper_rows_are_refused() {
|
||||
let table = identity_3dl(&[0, 1, 2, 3], 4);
|
||||
let body = table.split_once('\n').unwrap().1;
|
||||
assert!(parse_3dl(&format!("0 341 junk 682 1023\n{body}")).is_err(), "a junk token");
|
||||
assert!(parse_3dl(&format!("0 114 455 1023\n0 114 455 1023\n{body}")).is_err(), "two shaper rows");
|
||||
assert!(parse_3dl(&format!("0 114 455 1023\n{body}")).is_ok());
|
||||
}
|
||||
|
||||
/// #9170 review (bommerts): the standard `3DMESH` / `Title lut 12` preamble still reads; only
|
||||
/// the rows after it are parsed strictly.
|
||||
#[test]
|
||||
fn three_dl_mesh_preamble_is_accepted() {
|
||||
let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round();
|
||||
let plain = identity_3dl(&[0, 114, 455, 1023], 4);
|
||||
let headed = parse_3dl(&format!("3DMESH\nTitle lut 12\n{plain}")).unwrap();
|
||||
assert_eq!(headed, parse_3dl(&plain).unwrap(), "the preamble changes nothing");
|
||||
assert_eq!(grey(&headed), 71.0, "the bent shaper still applies");
|
||||
let even = identity_3dl(&[0, 341, 682, 1023], 4);
|
||||
assert_eq!(grey(&parse_3dl(&format!("3dmesh\nTitle lut 12\n{even}")).unwrap()), 128.0);
|
||||
assert!(
|
||||
parse_3dl(&format!("3DMESH\nTitle lut 12\n0 341 junk 682 1023\n{}", even.split_once('\n').unwrap().1)).is_err(),
|
||||
"still strict after it"
|
||||
);
|
||||
}
|
||||
|
||||
/// #9170 review: a shaper that isn't finite is refused, not dropped, so a Color Lookup never
|
||||
/// renders the table alone for a file whose shaper it can't apply.
|
||||
#[test]
|
||||
fn three_dl_non_finite_shaper_is_refused() {
|
||||
let body = identity_3dl(&[0, 1, 2, 3], 4);
|
||||
let body = body.split_once('\n').unwrap().1;
|
||||
for shaper in ["0 NaN 682 1023", "0 341 inf 1023", "-inf 341 682 1023"] {
|
||||
assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #9170 review: a shaper row that starts with `NaN` or `inf` is a shaper, not a header, so
|
||||
/// it reaches the non-finite refusal instead of being skipped.
|
||||
#[test]
|
||||
fn three_dl_shaper_starting_non_finite_is_refused() {
|
||||
let body = identity_3dl(&[0, 1, 2, 3], 4);
|
||||
let body = body.split_once('\n').unwrap().1;
|
||||
for shaper in ["NaN 341 682 1023", "inf 341 682 1023", "-inf 341 682 1023"] {
|
||||
assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #9170 review: only a line starting with a word is a header. One whose first token looks
|
||||
/// like a number but isn't (`0oops`) is read strictly and refused, not skipped with its shaper.
|
||||
#[test]
|
||||
fn three_dl_malformed_first_token_is_refused() {
|
||||
let body = identity_3dl(&[0, 1, 2, 3], 4);
|
||||
let body = body.split_once('\n').unwrap().1;
|
||||
for shaper in ["0oops 114 455 1023", "-x 114 455 1023", ".5x 114 455 1023", "+ 114 455 1023"] {
|
||||
assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}");
|
||||
}
|
||||
let (first, rest) = body.split_once('\n').unwrap();
|
||||
assert!(parse_3dl(&format!("0 114 455 1023\n{first}\n1x 0 0\n{rest}")).is_err(), "a table row too");
|
||||
assert!(parse_3dl(&format!("3DMESH\nTitle lut 12\n0 114 455 1023\n{body}")).is_ok(), "headers still skip");
|
||||
}
|
||||
|
||||
/// #9170 review: a bent shaper whose knots can't all be kept within MAX_SIZE is refused rather
|
||||
/// than sampled where it may look even (here a 257-point shaper bent only at odd knots, whose
|
||||
/// 129-node fallback would read the identity).
|
||||
#[test]
|
||||
fn three_dl_shaper_too_fine_to_keep_is_refused() {
|
||||
let shaper: Vec<u32> = (0..257u32).map(|i| (i * 1023 / 256 + if i % 2 == 1 { 40 } else { 0 }).min(1023)).collect();
|
||||
assert!(parse_3dl(&identity_3dl(&shaper, 2)).is_err());
|
||||
// As many knots as nodes stays within reach.
|
||||
let same: Vec<u32> = (0..17u32).map(|i| (1023.0 * (f64::from(i) / 16.0).powi(2)).round() as u32).collect();
|
||||
assert!(parse_3dl(&identity_3dl(&same, 17)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_title_with_hash_round_trips() {
|
||||
let l = LutFile::from_fn("Look #3", 2, |c| c).unwrap();
|
||||
assert_eq!(parse_cube(&write_cube(&l)).unwrap().title, "Look #3");
|
||||
let t = "TITLE \"A #b\" # note\nLUT_3D_SIZE 2 # c\n# comment\n";
|
||||
let rows = "0 0 0 # x\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
|
||||
let p = parse_cube(&format!("{t}{rows}")).unwrap();
|
||||
assert_eq!(p.title, "A #b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_cube_keeps_non_default_domain() {
|
||||
let mut l = LutFile::identity(2).unwrap();
|
||||
l.domain_min = [0.1; 3];
|
||||
l.domain_max = [0.9; 3];
|
||||
let back = parse_cube(&write_cube(&l)).unwrap();
|
||||
assert_eq!(back.domain_min, [0.1; 3]);
|
||||
assert_eq!(back.domain_max, [0.9; 3]);
|
||||
l.domain_min = [0.0, 0.0, 0.5];
|
||||
l.domain_max = [1.0; 3];
|
||||
let back = parse_cube(&write_cube(&l)).unwrap();
|
||||
assert_eq!(back.domain_min, [0.0, 0.0, 0.5]);
|
||||
assert_eq!(back.domain_max, [1.0; 3]);
|
||||
assert!(!write_cube(&LutFile::identity(2).unwrap()).contains("DOMAIN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_domain_is_kept_and_validated() {
|
||||
let base = "TITLE \"t\"
|
||||
@@ -403,6 +869,26 @@ DOMAIN_MAX 0.5 1 1
|
||||
assert!(parse_cube(&text).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_dl_without_a_header_reads_low_nodes_as_12_bit() {
|
||||
let table = |peak: u32| -> String {
|
||||
let mut text = String::from("0 1023\n");
|
||||
for i in 0..8 {
|
||||
let on = |bit: u32| if i & bit != 0 { peak } else { 0 };
|
||||
text.push_str(&format!("{} {} {}\n", on(4), on(2), on(1)));
|
||||
}
|
||||
text
|
||||
};
|
||||
for peak in [255, 1023, 4095] {
|
||||
let white = parse_3dl(&table(peak)).unwrap().data[21..24].to_vec();
|
||||
assert_eq!(white, [peak as f32 / 4095.0; 3], "peak {peak}");
|
||||
}
|
||||
// A header keeps the inferred 10-bit depth. The bare `3DMESH` magic is a header without a
|
||||
// cube declaration; `Mesh 1 10` would declare a 3³ cube and refuse this 2³ table (#9078).
|
||||
let white = parse_3dl(&format!("3DMESH\n{}", table(1023))).unwrap().data[21..24].to_vec();
|
||||
assert_eq!(white, [1.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_dl_blue_fastest_and_bit_depth() {
|
||||
let mut text = String::from("0 1023\n");
|
||||
@@ -418,6 +904,143 @@ DOMAIN_MAX 0.5 1 1
|
||||
assert_eq!(l.data, LutFile::identity(2).unwrap().data);
|
||||
}
|
||||
|
||||
/// A `size`³ inverse-identity `.3dl` (blue fastest): node (r,g,b) outputs `max - max·v/(size-1)`
|
||||
/// per channel, with a uniform shaper of `size` codes up to 1023.
|
||||
fn mesh_3dl(n_bits: u32, out_bits: u32, size: usize, max: f64, shaper: bool) -> String {
|
||||
let mut text = format!("3DMESH\nMesh {n_bits} {out_bits}\n");
|
||||
if shaper {
|
||||
for i in 0..size {
|
||||
if i > 0 {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&format!("{}", (1023.0 * i as f64 / (size - 1) as f64).round()));
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
for r in 0..size {
|
||||
for g in 0..size {
|
||||
for b in 0..size {
|
||||
let v = [r, g, b].map(|c| (max - max * c as f64 / (size - 1) as f64).round());
|
||||
text.push_str(&format!("{} {} {}\n", v[0], v[1], v[2]));
|
||||
}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_dl_mesh_three_point_shaper_is_not_a_node() {
|
||||
// #9078: Photoshop loads a 3³ cube whose shaper line has three values; counting it as a
|
||||
// 28th node rejected the whole file.
|
||||
let l = parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, true)).unwrap();
|
||||
assert_eq!(l.size, 3);
|
||||
assert_eq!(l.data.len(), 3 * 3 * 3 * 3);
|
||||
// The inverse identity: node (0,0,0) is full, node (2,2,2) black.
|
||||
assert!((l.data[0] - 1.0).abs() < 1e-6 && (l.data[1] - 1.0).abs() < 1e-6 && (l.data[2] - 1.0).abs() < 1e-6);
|
||||
let last = &l.data[l.data.len() - 3..];
|
||||
assert_eq!(last, [0.0, 0.0, 0.0]);
|
||||
// The same 27 nodes without the shaper row still load (the node-only control).
|
||||
assert_eq!(parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, false)).unwrap().data, l.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_dl_mesh_declares_the_output_depth() {
|
||||
// #9073: identical node codes divide by the declared range, not by the largest value.
|
||||
let mut text_10 = String::new();
|
||||
let mut text_12 = String::new();
|
||||
for (n_bits, out_bits, dst) in [(2, 10, &mut text_10), (2, 12, &mut text_12)] {
|
||||
// A 5³ identity scaled to 10-bit codes (max 1023).
|
||||
*dst = format!("3DMESH\nMesh {n_bits} {out_bits}\n0 256 512 768 1023\n");
|
||||
for r in 0..5 {
|
||||
for g in 0..5 {
|
||||
for b in 0..5 {
|
||||
dst.push_str(&format!("{} {} {}\n", r * 255, g * 255, b * 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let white_10 = parse_3dl(&text_10).unwrap();
|
||||
let white_12 = parse_3dl(&text_12).unwrap();
|
||||
assert_eq!(white_10.size, 5);
|
||||
let last_10 = &white_10.data[white_10.data.len() - 3..];
|
||||
let last_12 = &white_12.data[white_12.data.len() - 3..];
|
||||
assert!((last_10[0] - 1020.0 / 1023.0).abs() < 1e-6, "{last_10:?}");
|
||||
// 1020/4095 ≈ 0.249: white renders grey, as Photoshop measures it.
|
||||
assert!((last_12[0] - 1020.0 / 4095.0).abs() < 1e-6, "{last_12:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_dl_mesh_header_mismatch_is_rejected() {
|
||||
// A Mesh header that disagrees with the row count is refused, not silently re-inferred.
|
||||
assert!(parse_3dl("3DMESH\nMesh 1 12\n0 0 0\n0 0 1\n").is_err());
|
||||
// A node beyond the declared range is a wrong declaration: the old inference from the
|
||||
// largest value keeps the file loading as it did before the header was honoured.
|
||||
let mut text = String::from("3DMESH\nMesh 1 10\n");
|
||||
for r in 0..3 {
|
||||
for g in 0..3 {
|
||||
for b in 0..3 {
|
||||
text.push_str(&format!("{} {} {}\n", r * 2047, g * 2047, b * 2047));
|
||||
}
|
||||
}
|
||||
}
|
||||
let l = parse_3dl(&text).unwrap();
|
||||
let last = &l.data[l.data.len() - 3..];
|
||||
assert!((last[0] - 4094.0 / 4095.0).abs() < 1e-6, "{last:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_dl_mesh_extra_node_cannot_replace_a_captured_shaper() {
|
||||
for (bits, size) in [(2, 5), (3, 9)] {
|
||||
let mut text = mesh_3dl(bits, 12, size, 4095.0, true);
|
||||
let valid = parse_3dl(&text).unwrap();
|
||||
assert_eq!(valid.size, size);
|
||||
assert_eq!(valid.data, parse_3dl(&mesh_3dl(bits, 12, size, 4095.0, false)).unwrap().data);
|
||||
text.push_str("4095 4095 4095\n");
|
||||
let error = parse_3dl(&text).unwrap_err().to_string();
|
||||
assert!(error.contains(&format!("expected {} rows", size * size * size)), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_dl_malformed_mesh_header_falls_back_to_inference() {
|
||||
// A `Mesh` line that is not exactly two integer fields is not a declaration: it is
|
||||
// ignored and the table is inferred as if headerless, rather than recovering numbers
|
||||
// from the wrong positions (#9078). A 2³ cube would otherwise be read as a 3³ cube.
|
||||
for header in ["Mesh invalid 1 12", "Mesh 1 12 13", "Mesh 1 x", "Mesh 1"] {
|
||||
let mut text = format!("3DMESH\n{header}\n");
|
||||
for r in 0..2 {
|
||||
for g in 0..2 {
|
||||
for b in 0..2 {
|
||||
text.push_str(&format!("{} {} {}\n", r * 1023, g * 1023, b * 1023));
|
||||
}
|
||||
}
|
||||
}
|
||||
let l = parse_3dl(&text).unwrap();
|
||||
assert_eq!(l.size, 2, "{header}");
|
||||
assert_eq!(l.data, LutFile::identity(2).unwrap().data, "{header}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_dl_mesh_uses_declared_range_for_small_integer_codes() {
|
||||
// #9073: under a valid `Mesh` declaration the node codes are integers in the declared
|
||||
// range, so a max value of 1 is 1/4095 of full output — not full output itself.
|
||||
let mut text = String::from("3DMESH\nMesh 1 12\n");
|
||||
for r in 0..3 {
|
||||
for g in 0..3 {
|
||||
for b in 0..3 {
|
||||
let v = u8::from(r == 0 && g == 0 && b == 0);
|
||||
text.push_str(&format!("{v} {v} {v}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let l = parse_3dl(&text).unwrap();
|
||||
assert_eq!(l.size, 3);
|
||||
// blue fastest: node (0,0,0) is the first entry, code 1 → 1/4095.
|
||||
assert!((l.data[0] - 1.0 / 4095.0).abs() < 1e-6, "{:?}", &l.data[0..3]);
|
||||
assert!(l.data[3..].iter().all(|&v| v == 0.0), "{:?}", l.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_hex_floats() {
|
||||
let id = LutFile::identity(2).unwrap();
|
||||
@@ -426,12 +1049,81 @@ DOMAIN_MAX 0.5 1 1
|
||||
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_reads_utf16_with_either_byte_order() {
|
||||
let id = LutFile::identity(2).unwrap();
|
||||
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\" encoding=\"UTF-16\"?><look><LUT><size>\"2\"</size><data>\"{hex}\"</data></LUT></look>");
|
||||
let le: Vec<u8> = [0xFF, 0xFE].into_iter().chain(xml.encode_utf16().flat_map(u16::to_le_bytes)).collect();
|
||||
let be: Vec<u8> = [0xFE, 0xFF].into_iter().chain(xml.encode_utf16().flat_map(u16::to_be_bytes)).collect();
|
||||
assert_eq!(parse("a.look", &le).unwrap().data, id.data);
|
||||
assert_eq!(parse("a.look", &be).unwrap().data, id.data);
|
||||
// Sniffed as XML through the byte-order mark too, so the recorded format is the one parsed.
|
||||
assert_eq!(Format::detect("a.lut", &le), Format::Look);
|
||||
assert_eq!(parse_as(Format::Look, &be).unwrap().data, id.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_ignores_tags_inside_xml_comments() {
|
||||
let hex = |l: &LutFile| -> String { l.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect() };
|
||||
let inverse = LutFile::from_fn("", 2, |[r, g, b]| [1.0 - r, 1.0 - g, 1.0 - b]).unwrap();
|
||||
let xml = format!(
|
||||
"<look><!-- <size>\"3\"</size><data>\"{}\"</data> --><LUT><size>\"2\"</size><data>\"{}\"</data></LUT><!-- unterminated <data>00",
|
||||
hex(&LutFile::identity(2).unwrap()),
|
||||
hex(&inverse)
|
||||
);
|
||||
assert_eq!(parse("commented.look", xml.as_bytes()).unwrap().data, inverse.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_rgb_and_rgba_allow_whitespace_in_hex_data() {
|
||||
let id = LutFile::identity(2).unwrap();
|
||||
for channels in [3, 4] {
|
||||
let mut hex = String::new();
|
||||
for rgb in id.data.as_chunks::<3>().0 {
|
||||
for value in rgb.iter().copied().chain((channels == 4).then_some(1.0)) {
|
||||
for byte in value.to_le_bytes() {
|
||||
hex.push_str(&format!("{byte:02x} \t\r\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let xml = format!("<look><size>2</size><data>\"{hex}\"</data></look>");
|
||||
assert_eq!(parse("valid.look", xml.as_bytes()).unwrap().data, id.data);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_rejects_partial_floats_and_invalid_hex() {
|
||||
let id = LutFile::identity(2).unwrap();
|
||||
let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
|
||||
for suffix in ["0", "00", "0000", "000000", "G", "é"] {
|
||||
let xml = format!("<look><size>2</size><data>{hex}{suffix}</data></look>");
|
||||
assert!(parse("bad.look", xml.as_bytes()).is_err(), "accepted malformed suffix {suffix:?}");
|
||||
}
|
||||
let invalid = hex.replacen('0', "G", 1);
|
||||
let xml = format!("<look><size>2</size><data>{invalid}</data></look>");
|
||||
assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("invalid hexadecimal"));
|
||||
let xml = format!("<look><size>2</size><data>{hex}00</data></look>");
|
||||
assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("incomplete float"));
|
||||
}
|
||||
|
||||
#[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 format_detection_matches_parse() {
|
||||
assert_eq!(Format::detect("a.3DL", b"<look>"), Format::ThreeDl);
|
||||
assert_eq!(Format::detect("a.Look", b""), Format::Look);
|
||||
assert_eq!(Format::detect("a.cube", b" <look>"), Format::Look, "XML is sniffed as .look");
|
||||
assert_eq!(Format::detect("a.txt", b"LUT_3D_SIZE 2"), Format::Cube);
|
||||
assert_eq!(Format::ThreeDl.extension(), "3dl");
|
||||
let text = write_cube(&LutFile::identity(2).unwrap());
|
||||
assert_eq!(parse_as(Format::Cube, text.as_bytes()), parse("a.cube", text.as_bytes()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_rejected() {
|
||||
assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err());
|
||||
|
||||
@@ -248,6 +248,16 @@ impl Pipeline {
|
||||
out[..self.outputs].copy_from_slice(&v[..self.outputs]);
|
||||
}
|
||||
|
||||
/// Evaluates the stages after the first one on values already passed through it.
|
||||
#[inline]
|
||||
pub(crate) fn eval_after_first(&self, v: &mut [f32; MAX], out: &mut [f32]) {
|
||||
let mut n = self.inputs;
|
||||
for (s, edges) in self.stages.iter().zip(&self.edges).skip(1) {
|
||||
n = s.run(v, n, edges);
|
||||
}
|
||||
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());
|
||||
|
||||
+140
-39
@@ -443,10 +443,18 @@ impl<'a> Reader<'a> {
|
||||
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)?])
|
||||
let r = Reader { b: self.slice(off, 12)? };
|
||||
Ok([r.s15f16(0)?, r.s15f16(4)?, r.s15f16(8)?])
|
||||
}
|
||||
}
|
||||
|
||||
/// `base + n * size` for a count or offset read from the file, or `Truncated` when it does not
|
||||
/// fit a `usize`. Such fields reach `u32::MAX`, which overflows on 32-bit targets (wasm32):
|
||||
/// a panic with overflow checks on, a wrapped offset that reads the wrong bytes without (#4976).
|
||||
fn extent(base: usize, n: usize, size: usize) -> Result<usize, CmsError> {
|
||||
n.checked_mul(size).and_then(|len| base.checked_add(len)).ok_or(CmsError::Truncated)
|
||||
}
|
||||
|
||||
fn sig(s: &[u8; 4]) -> u32 {
|
||||
u32::from_be_bytes(*s)
|
||||
}
|
||||
@@ -736,41 +744,8 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
|
||||
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);
|
||||
// Offsets come straight from the file: each element is cut out with one checked slice
|
||||
// and read with local offsets, so no `off + k` can wrap (wasm32, overflow checks off).
|
||||
let matrix = |off: usize| -> Result<Stage, CmsError> {
|
||||
let e = Reader { b: r.slice(off, 48)? };
|
||||
let mut m = vec![0.0; 9];
|
||||
for (k, v) in m.iter_mut().enumerate() {
|
||||
*v = e.s15f16(k * 4)?;
|
||||
}
|
||||
let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?];
|
||||
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
|
||||
};
|
||||
let clut = |off: usize, cin: usize, cout: usize| -> Result<Stage, CmsError> {
|
||||
let h = Reader { b: r.slice(off, 20)? };
|
||||
let mut grid = Vec::with_capacity(cin);
|
||||
for k in 0..cin {
|
||||
let g = h.u8(k)? as usize;
|
||||
if g < 1 {
|
||||
return Err(CmsError::Invalid("zero grid points".into()));
|
||||
}
|
||||
grid.push(g);
|
||||
}
|
||||
let prec = h.u8(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;
|
||||
charge_clut_values(total)?;
|
||||
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 matrix = |off: usize| lut_ab_matrix(&r, off);
|
||||
let clut = |off: usize, cin: usize, cout: usize| lut_ab_clut(&r, off, cin, cout);
|
||||
let mut stages = Vec::new();
|
||||
if ty == b"mAB " {
|
||||
// A curves → CLUT → M curves → matrix → B curves.
|
||||
@@ -817,6 +792,46 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 3×3 matrix and offset of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
|
||||
fn lut_ab_matrix(r: &Reader, off: usize) -> Result<Stage, CmsError> {
|
||||
// One bounds-checked slice, then offsets inside it: `off + 44` need not fit a `usize`.
|
||||
let e = Reader { b: r.slice(off, 48)? };
|
||||
let mut m = vec![0.0; 9];
|
||||
for (k, v) in m.iter_mut().enumerate() {
|
||||
*v = e.s15f16(k * 4)?;
|
||||
}
|
||||
let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?];
|
||||
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
|
||||
}
|
||||
|
||||
/// The CLUT of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
|
||||
fn lut_ab_clut(r: &Reader, off: usize, cin: usize, cout: usize) -> Result<Stage, CmsError> {
|
||||
// Grid points (16 bytes), precision and padding; the samples follow.
|
||||
let head = Reader { b: r.slice(off, 20)? };
|
||||
let mut grid = Vec::with_capacity(cin);
|
||||
for k in 0..cin {
|
||||
let g = head.u8(k)? as usize;
|
||||
if g < 1 {
|
||||
return Err(CmsError::Invalid("zero grid points".into()));
|
||||
}
|
||||
grid.push(g);
|
||||
}
|
||||
let prec = head.u8(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;
|
||||
charge_clut_values(total)?;
|
||||
let samples = extent(off, 20, 1)?;
|
||||
let data: Vec<f32> = match prec {
|
||||
1 => r.slice(samples, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
|
||||
2 => r.slice(samples, 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)?))
|
||||
}
|
||||
|
||||
/// Text from `desc` (textDescriptionType), `mluc` (English or first record) or `text`.
|
||||
fn parse_text(d: &[u8]) -> String {
|
||||
let r = Reader { b: d };
|
||||
@@ -888,7 +903,92 @@ mod tests {
|
||||
d.extend_from_slice(&2u32.to_be_bytes());
|
||||
d.extend_from_slice(&(u32::MAX - 20).to_be_bytes());
|
||||
d.extend_from_slice(&[0u8; 12]);
|
||||
assert!(parse_text(&d).is_empty());
|
||||
assert!(parse_text(&d).is_empty())
|
||||
}
|
||||
|
||||
fn be(v: u32) -> [u8; 4] {
|
||||
v.to_be_bytes()
|
||||
}
|
||||
|
||||
/// #4976: offsets and counts stored in a profile reach `u32::MAX`, which is `usize::MAX` on
|
||||
/// 32-bit targets. The arithmetic on them must report a truncated profile, not overflow.
|
||||
#[test]
|
||||
fn offsets_at_the_top_of_the_address_space_are_truncated_not_overflowed() {
|
||||
let bytes = [0u8; 64];
|
||||
let r = Reader { b: &bytes };
|
||||
for off in [usize::MAX, usize::MAX - 3, usize::MAX - 11, usize::MAX - 19, usize::MAX - 47] {
|
||||
assert_eq!(r.xyz(off), Err(CmsError::Truncated), "xyz at {off}");
|
||||
assert!(matches!(lut_ab_matrix(&r, off), Err(CmsError::Truncated)), "matrix at {off}");
|
||||
assert!(matches!(lut_ab_clut(&r, off, 3, 3), Err(CmsError::Truncated)), "clut at {off}");
|
||||
}
|
||||
assert_eq!(extent(16, usize::MAX, 12), Err(CmsError::Truncated));
|
||||
assert_eq!(extent(usize::MAX, 1, 1), Err(CmsError::Truncated));
|
||||
assert_eq!(extent(16, 3, 12), Ok(52));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_count_is_checked_before_its_length_is_computed() {
|
||||
let mut d = b"curv\0\0\0\0".to_vec();
|
||||
d.extend(be(u32::MAX));
|
||||
assert_eq!(parse_curve(&d).map(|c| c.1), Err(CmsError::Invalid("curve too long".into())));
|
||||
// Controls: the identity and a two-point table still parse with their lengths.
|
||||
let mut d = b"curv\0\0\0\0".to_vec();
|
||||
d.extend(be(0));
|
||||
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(12));
|
||||
let mut d = b"curv\0\0\0\0".to_vec();
|
||||
d.extend(be(2));
|
||||
d.extend([0, 0, 0xFF, 0xFF]);
|
||||
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lut_ab_elements_at_hostile_offsets_are_rejected() {
|
||||
// A 3→3 lutAtoB with identity B curves at byte 32; then its B-curve, matrix and CLUT
|
||||
// offsets in turn point at the end of a 32-bit file.
|
||||
let lut = |b: u32, matrix: u32, m: u32, clut: u32, a: u32| {
|
||||
let mut d = b"mAB \0\0\0\0".to_vec();
|
||||
d.extend([3, 3, 0, 0]);
|
||||
for v in [b, matrix, m, clut, a] {
|
||||
d.extend(be(v));
|
||||
}
|
||||
for _ in 0..3 {
|
||||
d.extend(b"curv\0\0\0\0");
|
||||
d.extend(be(0));
|
||||
}
|
||||
d
|
||||
};
|
||||
assert!(parse_lut(&lut(32, 0, 0, 0, 0), true).is_ok(), "the control parses");
|
||||
let top = u32::MAX - 8;
|
||||
assert!(parse_lut(&lut(top, 0, 0, 0, 0), true).is_err(), "B curves offset");
|
||||
assert!(parse_lut(&lut(32, top, 32, 0, 0), true).is_err(), "matrix offset");
|
||||
assert!(parse_lut(&lut(32, 0, 0, top, 32), true).is_err(), "CLUT offset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multilingual_records_with_a_huge_record_size_give_the_first_record() {
|
||||
let record = |lang: &[u8; 2], len: u32, off: u32| {
|
||||
let mut r = lang.to_vec();
|
||||
r.extend(b"US");
|
||||
r.extend(be(len));
|
||||
r.extend(be(off));
|
||||
r
|
||||
};
|
||||
// Two records of the standard 12 bytes: the English one wins.
|
||||
let mut d = b"mluc\0\0\0\0".to_vec();
|
||||
d.extend(be(2));
|
||||
d.extend(be(12));
|
||||
d.extend(record(b"de", 4, 40));
|
||||
d.extend(record(b"en", 4, 44));
|
||||
d.extend([0, b'D', 0, b'E', 0, b'E', 0, b'N']);
|
||||
assert_eq!(parse_text(&d), "EN");
|
||||
// A record size of u32::MAX: the second record lies past any file, so the first is used.
|
||||
d[12..16].copy_from_slice(&be(u32::MAX));
|
||||
assert_eq!(parse_text(&d), "DE");
|
||||
// No records at all.
|
||||
let mut d = b"mluc\0\0\0\0".to_vec();
|
||||
d.extend(be(u32::MAX));
|
||||
d.extend(be(u32::MAX));
|
||||
assert_eq!(parse_text(&d), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -911,13 +1011,14 @@ mod tests {
|
||||
}
|
||||
|
||||
/// `p` with the content hash of `other`: the hash is a fixed-key 64-bit SipHash, so a
|
||||
/// crafted profile can have another's. Setting it stands in for finding one.
|
||||
/// crafted profile can have another's (#8613). Setting it stands in for finding one.
|
||||
fn with_hash_of(p: &Profile, other: &Profile) -> Profile {
|
||||
let p = Profile { hash: std::sync::OnceLock::from(other.content_hash()), ..p.clone() };
|
||||
assert_eq!(p.content_hash(), other.content_hash());
|
||||
p
|
||||
}
|
||||
|
||||
/// #8613: a profile that shares sRGB's hash is not taken for sRGB.
|
||||
#[test]
|
||||
fn a_hash_collision_is_not_the_same_colours() {
|
||||
let srgb = Builtin::Srgb.profile();
|
||||
@@ -930,6 +1031,7 @@ mod tests {
|
||||
assert!(Builtin::DisplayP3.profile().same_colors(Builtin::DisplayP3.profile()));
|
||||
}
|
||||
|
||||
/// #8613: the transform cache does not hand a colliding profile the other's transform.
|
||||
#[test]
|
||||
fn a_hash_collision_does_not_get_the_cached_transform() {
|
||||
use crate::transform::{Transform, TransformOptions, cached};
|
||||
@@ -950,7 +1052,6 @@ mod tests {
|
||||
assert_eq!(eval(&cached(srgb, lab, opts).unwrap()), sr);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parse_budget_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -33,7 +33,7 @@ use crate::pipeline::Stage;
|
||||
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
|
||||
use crate::{CmsError, Intent, invalid_arg};
|
||||
|
||||
pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
|
||||
pub const DESCRIPTION: &str = "PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
|
||||
|
||||
/// Parameters of the synthetic profile.
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
+142
-6
@@ -2,14 +2,14 @@
|
||||
//! compensation, soft-proofing chains, and fast lookup-table evaluation of pixel buffers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
|
||||
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, check_len, invalid_arg};
|
||||
use crate::{CmsError, GpuMatrixShaper, Intent, check_len, invalid_arg};
|
||||
|
||||
/// Sample storage of a byte buffer for [`Transform::convert_bytes_many`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -39,9 +39,11 @@ impl Default for TransformOptions {
|
||||
/// 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;
|
||||
const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS;
|
||||
/// Resolution of output shaper tables (indexed by `x^(1/4)`).
|
||||
const OUT_TABLE: usize = 4096;
|
||||
const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES;
|
||||
// The blocked matrix/TRC path wraps table indices with a mask instead of a bounds check.
|
||||
const _: () = assert!(OUT_TABLE.is_power_of_two());
|
||||
|
||||
/// 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];
|
||||
@@ -91,6 +93,8 @@ pub struct Transform {
|
||||
post: Option<Post>,
|
||||
/// Pre-located grid coordinates per 8-bit code: (offset contribution, fraction) per input.
|
||||
grid8: Vec<[(u32, f32); 256]>,
|
||||
/// For an exact core: the first stage's curves at every 8-bit code.
|
||||
exact8: Vec<[f32; 256]>,
|
||||
white: Option<White>,
|
||||
}
|
||||
|
||||
@@ -202,7 +206,13 @@ impl Transform {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(Transform { inputs, outputs, opts, pipeline, core, post, grid8, white: None })
|
||||
let exact8 = match (&core, pipeline.stages().first()) {
|
||||
(Core::Exact, Some(Stage::Curves(cs))) if cs.len() == inputs && inputs <= 4 => {
|
||||
cs.iter().map(|c| std::array::from_fn(|i| c.eval(i as f32 / 255.0))).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(Transform { inputs, outputs, opts, pipeline, core, post, grid8, exact8, white: None })
|
||||
}
|
||||
|
||||
/// White preservation: the source's device white maps to exactly the destination's device
|
||||
@@ -279,6 +289,31 @@ impl Transform {
|
||||
&self.pipeline
|
||||
}
|
||||
|
||||
/// A separable RGB transform for accurate GPU display conversion, when available.
|
||||
///
|
||||
/// Reuses the input/matrix/output acceleration of the integer conversion path instead
|
||||
/// of interpolating final encoded RGB over a coarse 3D grid. Returns `None` for other
|
||||
/// channel counts, CLUTs, proofing stages, or invalid/unavailable table data, so callers
|
||||
/// can keep their existing display-LUT fallback. This is an SDR representation; see
|
||||
/// [`GpuMatrixShaper`] for the precise sampling and clamping contract.
|
||||
pub fn gpu_matrix_shaper(&self) -> Option<GpuMatrixShaper> {
|
||||
if self.inputs != 3 || self.outputs != 3 {
|
||||
return None;
|
||||
}
|
||||
let Core::Shaper { curves, matrix, .. } = &self.core else { return None };
|
||||
let mut rows = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
|
||||
if let Some((nr, nc, coefficients, offsets)) = matrix {
|
||||
if *nr != 3 || *nc != 3 || coefficients.len() != 9 || offsets.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
for (row, (coefficients, offset)) in rows.iter_mut().zip(coefficients.as_chunks::<3>().0.iter().zip(offsets)) {
|
||||
let [r, g, b] = coefficients;
|
||||
*row = [*r, *g, *b, *offset];
|
||||
}
|
||||
}
|
||||
GpuMatrixShaper::from_parts(rows, curves, self.post.as_ref().map(|p| p.tables.as_slice()))
|
||||
}
|
||||
|
||||
/// Exact evaluation of one colour: `input` holds at least `inputs` normalised values, `out`
|
||||
/// receives `outputs` values.
|
||||
pub fn eval(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> {
|
||||
@@ -450,6 +485,10 @@ impl Transform {
|
||||
}
|
||||
}
|
||||
}
|
||||
Core::Shaper { curves8, matrix: Some((3, 3, m, off)), .. } if self.shaper3_ok(curves8.len(), m.len(), off.len()) => {
|
||||
let load = |sp: &[u8]| [curves8[0][sp[0] as usize], curves8[1][sp[1] as usize], curves8[2][sp[2] as usize]];
|
||||
self.shaper3_blocks(s, ss, d, ds, extra, m, off, load, q);
|
||||
}
|
||||
Core::Shaper { curves8, matrix, .. } => {
|
||||
let mut v = [0.0f32; 16];
|
||||
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
|
||||
@@ -466,6 +505,27 @@ impl Transform {
|
||||
}
|
||||
}
|
||||
}
|
||||
Core::Exact if !self.exact8.is_empty() => {
|
||||
let mut inp = [0.0f32; 16];
|
||||
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
|
||||
let mut v = [0.0f32; 16];
|
||||
for k in 0..self.inputs {
|
||||
inp[k] = sp[k] as f32 / 255.0;
|
||||
v[k] = self.exact8[k][sp[k] as usize];
|
||||
}
|
||||
// Device white stays exact, as on the per-pixel exact path.
|
||||
match self.white_for(&inp) {
|
||||
Some(w) => out[..self.outputs].copy_from_slice(w),
|
||||
None => self.pipeline.eval_after_first(&mut v, &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)) {
|
||||
@@ -484,6 +544,74 @@ impl Transform {
|
||||
}
|
||||
}
|
||||
|
||||
fn shaper3_ok(&self, curves: usize, m: usize, off: usize) -> bool {
|
||||
self.inputs == 3
|
||||
&& self.outputs == 3
|
||||
&& curves == 3
|
||||
&& m == 9
|
||||
&& off == 3
|
||||
&& self.post.as_ref().is_some_and(|p| p.tables.len() == 3 && p.tables.iter().all(|t| t.len() == OUT_TABLE))
|
||||
}
|
||||
|
||||
/// Matrix/TRC transform of whole pixels in blocks: the matrix and the output-table index
|
||||
/// maths run over a block of pixels at a time (same operations, same order, per pixel).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shaper3_blocks<S: Copy>(
|
||||
&self,
|
||||
s: &[S],
|
||||
ss: usize,
|
||||
d: &mut [S],
|
||||
ds: usize,
|
||||
extra: usize,
|
||||
m: &[f32],
|
||||
off: &[f32],
|
||||
load: impl Fn(&[S]) -> [f32; 3],
|
||||
store: impl Fn(f32) -> S,
|
||||
) {
|
||||
const B: usize = 64;
|
||||
let Some(post) = self.post.as_ref() else { return };
|
||||
let tabs = [&post.tables[0][..OUT_TABLE], &post.tables[1][..OUT_TABLE], &post.tables[2][..OUT_TABLE]];
|
||||
let m: [f32; 9] = std::array::from_fn(|i| m[i]);
|
||||
let off: [f32; 3] = std::array::from_fn(|i| off[i]);
|
||||
let n_px = (s.len() / ss).min(d.len() / ds);
|
||||
let mut x = [[0.0f32; B]; 3];
|
||||
let mut start = 0;
|
||||
while start < n_px {
|
||||
let n = B.min(n_px - start);
|
||||
let sb = &s[start * ss..(start + n) * ss];
|
||||
let db = &mut d[start * ds..(start + n) * ds];
|
||||
for (j, sp) in sb.chunks_exact(ss).enumerate() {
|
||||
let [a, b, c] = load(sp);
|
||||
for r in 0..3 {
|
||||
let mut acc = off[r];
|
||||
acc += m[r * 3] * a;
|
||||
acc += m[r * 3 + 1] * b;
|
||||
acc += m[r * 3 + 2] * c;
|
||||
x[r][j] = acc;
|
||||
}
|
||||
}
|
||||
for (ch, t) in tabs.iter().enumerate() {
|
||||
let mut u = [0.0f32; B];
|
||||
let mut ix = [0i32; B];
|
||||
for j in 0..B {
|
||||
let v = x[ch][j].clamp(0.0, 1.0).sqrt().sqrt() * (OUT_TABLE - 1) as f32;
|
||||
let i = (v as i32).min(OUT_TABLE as i32 - 2);
|
||||
ix[j] = i;
|
||||
u[j] = v - i as f32;
|
||||
}
|
||||
for (j, dp) in db.chunks_exact_mut(ds).enumerate() {
|
||||
let i = ix[j] as usize & (OUT_TABLE - 1);
|
||||
let (v0, v1) = (t[i], t[(i + 1) & (OUT_TABLE - 1)]);
|
||||
dp[ch] = store(v0 + (v1 - v0) * u[j]);
|
||||
}
|
||||
}
|
||||
for (sp, dp) in sb.chunks_exact(ss).zip(db.chunks_exact_mut(ds)) {
|
||||
dp[3..3 + extra].copy_from_slice(&sp[3..3 + extra]);
|
||||
}
|
||||
start += n;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) -> Result<(), CmsError> {
|
||||
self.check_buffers(src.len(), src_stride, dst.len(), dst_stride)?;
|
||||
@@ -493,6 +621,14 @@ impl Transform {
|
||||
}
|
||||
|
||||
fn run_u16(&self, s: &[u16], ss: usize, d: &mut [u16], ds: usize, extra: usize) {
|
||||
if let Core::Shaper { curves, matrix: Some((3, 3, m, off)), .. } = &self.core
|
||||
&& self.shaper3_ok(curves.len(), m.len(), off.len())
|
||||
{
|
||||
let load = |sp: &[u16]| {
|
||||
[table(&curves[0], sp[0] as f32 / 65535.0), table(&curves[1], sp[1] as f32 / 65535.0), table(&curves[2], sp[2] as f32 / 65535.0)]
|
||||
};
|
||||
return self.shaper3_blocks(s, ss, d, ds, extra, m, off, load, |v: f32| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16);
|
||||
}
|
||||
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)) {
|
||||
@@ -828,7 +964,7 @@ pub fn cached(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result<Ar
|
||||
None => {}
|
||||
}
|
||||
let t = Arc::new(Transform::with_options(src, dst, opts)?);
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut c = cache.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if c.len() > 64 {
|
||||
c.clear();
|
||||
}
|
||||
|
||||
@@ -991,3 +991,93 @@ fn white_is_exact_on_every_path() {
|
||||
assert!(o[2] < 0.99, "{cmyk:?}: absolute paper {o:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocked matrix/TRC path (64 pixels at a time) must match the per-pixel evaluation for
|
||||
/// every pixel count around the block size, with and without extra channels.
|
||||
#[test]
|
||||
fn matrix_trc_blocks_match_per_pixel_eval() {
|
||||
let pairs = [(Builtin::Srgb, Builtin::DisplayP3), (Builtin::ProPhotoCompat, Builtin::Srgb), (Builtin::AdobeRgbCompat, Builtin::Rec2020)];
|
||||
for (a, b) in pairs {
|
||||
let t = Transform::new(a.profile(), b.profile(), Intent::RelativeColorimetric, true).unwrap();
|
||||
for &(ss, ds, copy) in &[(3usize, 3usize, false), (4, 4, true), (5, 4, true)] {
|
||||
let max = 20011;
|
||||
let seed = |i: usize| (i.wrapping_mul(2654435761) >> 7) as u32;
|
||||
let src16: Vec<u16> = (0..max * ss).map(|i| (seed(i) >> 3) as u16).collect();
|
||||
let src8: Vec<u8> = (0..max * ss).map(|i| (seed(i) >> 11) as u8).collect();
|
||||
let mut full8 = vec![0u8; max * ds];
|
||||
t.convert_u8(&src8, ss, &mut full8, ds, copy).unwrap();
|
||||
for px in [1usize, 2, 63, 64, 65, 127, 128, 129, 1000] {
|
||||
let mut o16 = vec![0u16; px * ds];
|
||||
t.convert_u16(&src16[..px * ss], ss, &mut o16, ds, copy).unwrap();
|
||||
let mut o8 = vec![0u8; px * ds];
|
||||
t.convert_u8(&src8[..px * ss], ss, &mut o8, ds, copy).unwrap();
|
||||
assert_eq!(o8[..], full8[..px * ds], "u8 prefix of a longer buffer, {px} px");
|
||||
for p in 0..px {
|
||||
let inp: Vec<f32> = (0..3).map(|k| src16[p * ss + k] as f32 / 65535.0).collect();
|
||||
let mut want = [0.0f32; 3];
|
||||
t.eval_fast(&inp, &mut want).unwrap();
|
||||
for k in 0..3 {
|
||||
assert_eq!(o16[p * ds + k], (want[k].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16, "u16 px {p} ch {k} of {px}");
|
||||
}
|
||||
if copy && ds > 3 {
|
||||
assert_eq!(o16[p * ds + 3], src16[p * ss + 3]);
|
||||
assert_eq!(o8[p * ds + 3], src8[p * ss + 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 8-bit and 16-bit conversion through an analytic pipeline (no lookup table, here RGB to Lab)
|
||||
/// must equal the exact per-pixel evaluation. The 8-bit path tabulates the first stage's
|
||||
/// curves per code; `curves_are_tables` selects whether those curves are parametric (their
|
||||
/// `eval` is analytic) or `curv` tables (their `eval` interpolates inside the same loop).
|
||||
fn check_exact_core(src_profile: &Profile, curves_are_tables: bool) {
|
||||
let trc = src_profile.trc.as_ref().expect("matrix/TRC source");
|
||||
assert!(trc.iter().all(|c| matches!(c, craft_cms::Curve::Table(_)) == curves_are_tables), "TRC kind");
|
||||
let t = Transform::new(src_profile, lab(), Intent::RelativeColorimetric, true).unwrap();
|
||||
let px = 256usize * 256;
|
||||
let src: Vec<u8> = (0..px as u32).flat_map(|i| [(i & 255) as u8, (i >> 8) as u8, (i.wrapping_mul(2654435761) >> 13) as u8, 200]).collect();
|
||||
let mut out = vec![0u8; px * 4];
|
||||
t.convert_u8(&src, 4, &mut out, 4, true).unwrap();
|
||||
for (sp, dp) in src.as_chunks::<4>().0.iter().zip(out.as_chunks::<4>().0) {
|
||||
let inp = [sp[0] as f32 / 255.0, sp[1] as f32 / 255.0, sp[2] as f32 / 255.0];
|
||||
let mut want = [0.0f32; 3];
|
||||
t.eval(&inp, &mut want).unwrap();
|
||||
for k in 0..3 {
|
||||
assert_eq!(dp[k], (want[k].clamp(0.0, 1.0) * 255.0 + 0.5) as u8, "u8 {sp:?} ch {k}");
|
||||
}
|
||||
assert_eq!(dp[3], 200);
|
||||
}
|
||||
// 16-bit input stays off the 8-bit table and must agree with the untabulated evaluation.
|
||||
let src16: Vec<u16> = (0..px * 4).map(|i| if i % 4 == 3 { 51400 } else { (i.wrapping_mul(2654435761) >> 9) as u16 }).collect();
|
||||
let mut out16 = vec![0u16; px * 4];
|
||||
t.convert_u16(&src16, 4, &mut out16, 4, true).unwrap();
|
||||
for (sp, dp) in src16.as_chunks::<4>().0.iter().zip(out16.as_chunks::<4>().0) {
|
||||
let inp = [sp[0] as f32 / 65535.0, sp[1] as f32 / 65535.0, sp[2] as f32 / 65535.0];
|
||||
let mut want = [0.0f32; 3];
|
||||
t.eval(&inp, &mut want).unwrap();
|
||||
for k in 0..3 {
|
||||
assert_eq!(dp[k], (want[k].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16, "u16 {sp:?} ch {k}");
|
||||
}
|
||||
assert_eq!(dp[3], 51400);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_core_u8_matches_exact_eval() {
|
||||
check_exact_core(srgb(), false);
|
||||
}
|
||||
|
||||
/// Same, with `curv` table TRCs: the first-stage table is built from `Curve::eval`'s table
|
||||
/// branch and must still be applied to its own channel.
|
||||
#[test]
|
||||
fn exact_core_u8_matches_exact_eval_with_table_trc() {
|
||||
// Distinct tables per channel, so a first stage applied to the wrong channel shows up.
|
||||
let mut p = srgb_as_v2_tables();
|
||||
let table = |g: f64| craft_cms::Curve::Table((0..1024).map(|i| ((i as f64 / 1023.0).powf(g) * 65535.0).round() as f32 / 65535.0).collect());
|
||||
p.trc = Some([table(1.8), table(2.2), table(2.6)]);
|
||||
let table_trc = Profile::parse(&p.with_encoded_bytes().to_bytes()).unwrap();
|
||||
check_exact_core(&table_trc, true);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Independent ICC matrix/TRC reference for display shadows.
|
||||
//!
|
||||
//! Fixtures are generated here from the published sRGB equation and a synthetic monitor,
|
||||
//! with no photograph or device profile. The expected values implement ICC.1:2010 Annex F.3
|
||||
//! directly in f64, without Transform, Curve evaluation, or craft_cms::math:
|
||||
//! https://www.color.org/specification/ICC1v43_2010-12.pdf
|
||||
|
||||
use craft_cms::pipeline::{Pipeline, Stage};
|
||||
use craft_cms::{Builtin, Clut, Curve, GpuMatrixShaper, Intent, Lut3d, Profile, Transform, TransformOptions};
|
||||
|
||||
const SOURCE_MATRIX: [[f64; 3]; 3] = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]];
|
||||
// Deliberately synthetic, D50-referenced wider-gamut monitor; not a user's measured profile.
|
||||
const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.55, 0.27, 0.1442], [0.25, 0.69, 0.06], [0.01, 0.06, 0.7549]];
|
||||
const GAMMA: f64 = 2.2;
|
||||
const SOURCE_ENTRIES: usize = 1024;
|
||||
const DISPLAY_TOLERANCE: f64 = 1.0 / 255.0;
|
||||
|
||||
fn fixed16(x: f64) -> f64 {
|
||||
(x * 65536.0).round() / 65536.0
|
||||
}
|
||||
|
||||
fn srgb_sample(i: usize) -> f64 {
|
||||
let x = i as f64 / (SOURCE_ENTRIES - 1) as f64;
|
||||
let linear = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) };
|
||||
(linear * 65535.0).round() / 65535.0
|
||||
}
|
||||
|
||||
fn synthetic_profiles() -> (Profile, Profile) {
|
||||
let mut source = Builtin::Srgb.profile().clone();
|
||||
source.description = "Synthetic sampled sRGB for display regression".into();
|
||||
source.matrix = Some(SOURCE_MATRIX);
|
||||
source.trc = Some(std::array::from_fn(|_| Curve::Table((0..SOURCE_ENTRIES).map(|i| srgb_sample(i) as f32).collect())));
|
||||
let source = source.with_encoded_bytes();
|
||||
let mut monitor = Builtin::Srgb.profile().clone();
|
||||
monitor.description = "Synthetic gamma 2.2 display for regression".into();
|
||||
monitor.matrix = Some(MONITOR_MATRIX);
|
||||
monitor.trc = Some(std::array::from_fn(|_| Curve::Parametric { kind: 0, p: [GAMMA, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] }));
|
||||
let monitor = monitor.with_encoded_bytes();
|
||||
// Exercise actual ICC serialization/parsing, including fixed-point matrix/TRC values.
|
||||
(Profile::parse(&source.to_bytes()).unwrap(), Profile::parse(&monitor.to_bytes()).unwrap())
|
||||
}
|
||||
|
||||
fn synthetic_transform(bpc: bool) -> Transform {
|
||||
let (source, monitor) = synthetic_profiles();
|
||||
Transform::new(&source, &monitor, Intent::RelativeColorimetric, bpc).unwrap()
|
||||
}
|
||||
|
||||
fn reference(rgb: [f32; 3]) -> [f64; 3] {
|
||||
let linear = rgb.map(|x| {
|
||||
let p = f64::from(x.clamp(0.0, 1.0)) * (SOURCE_ENTRIES - 1) as f64;
|
||||
let i = (p as usize).min(SOURCE_ENTRIES - 2);
|
||||
srgb_sample(i) + (srgb_sample(i + 1) - srgb_sample(i)) * (p - i as f64)
|
||||
});
|
||||
let xyz = SOURCE_MATRIX.map(|row| row.iter().zip(linear).map(|(m, x)| fixed16(*m) * x).sum::<f64>());
|
||||
// Solve M_monitor * linear_monitor = XYZ using elimination, rather than the production
|
||||
// matrix inverse/multiplication implementation.
|
||||
let mut a: [[f64; 4]; 3] =
|
||||
std::array::from_fn(|r| [fixed16(MONITOR_MATRIX[r][0]), fixed16(MONITOR_MATRIX[r][1]), fixed16(MONITOR_MATRIX[r][2]), xyz[r]]);
|
||||
for pivot in 0..3 {
|
||||
let divisor = a[pivot][pivot];
|
||||
for value in &mut a[pivot][pivot..] {
|
||||
*value /= divisor;
|
||||
}
|
||||
let normalized = a[pivot];
|
||||
for (r, row) in a.iter_mut().enumerate() {
|
||||
if r != pivot {
|
||||
let factor = row[pivot];
|
||||
for (value, coefficient) in row.iter_mut().zip(normalized).skip(pivot) {
|
||||
*value -= factor * coefficient;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::array::from_fn(|c| a[c][3].clamp(0.0, 1.0).powf(1.0 / fixed16(GAMMA)))
|
||||
}
|
||||
|
||||
/// CPU interpretation of the public GPU data layout; expected colours come only from
|
||||
/// `reference`, so this tests the sampled representation, not Transform against itself.
|
||||
fn sample_gpu(gpu: &GpuMatrixShaper, rgb: [f32; 3]) -> [f32; 3] {
|
||||
fn interpolate(table: &[[f32; 4]], channel: usize, coordinate: f32) -> f32 {
|
||||
let i = (coordinate as usize).min(table.len() - 2);
|
||||
let f = coordinate - i as f32;
|
||||
table[i][channel] + (table[i + 1][channel] - table[i][channel]) * f
|
||||
}
|
||||
let linear: [f32; 3] =
|
||||
std::array::from_fn(|c| interpolate(gpu.input_table(), c, rgb[c].clamp(0.0, 1.0) * GpuMatrixShaper::INPUT_INTERVALS as f32));
|
||||
let rows = gpu.matrix_rows();
|
||||
std::array::from_fn(|c| {
|
||||
let row = rows[c];
|
||||
let value = row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2] + row[3];
|
||||
let coordinate = value.clamp(0.0, 1.0).sqrt().sqrt() * (GpuMatrixShaper::OUTPUT_ENTRIES - 1) as f32;
|
||||
interpolate(gpu.output_table(), c, coordinate).clamp(0.0, 1.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn check_reference(gpu: &GpuMatrixShaper, rgb: [f32; 3]) {
|
||||
let actual = sample_gpu(gpu, rgb);
|
||||
let expected = reference(rgb);
|
||||
for c in 0..3 {
|
||||
assert!((f64::from(actual[c]) - expected[c]).abs() <= DISPLAY_TOLERANCE, "input {rgb:?}, channel {c}: GPU {actual:?}, ICC {expected:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_srgb_to_gamma_display_preserves_neutral_and_colored_shadows() {
|
||||
for bpc in [false, true] {
|
||||
let transform = synthetic_transform(bpc);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
assert_eq!(gpu.input_table().len(), GpuMatrixShaper::INPUT_INTERVALS + 1);
|
||||
assert_eq!(gpu.output_table().len(), GpuMatrixShaper::OUTPUT_ENTRIES);
|
||||
assert!(gpu.input_table().iter().chain(gpu.output_table()).all(|v| v[3] == 1.0));
|
||||
for v in 0..=255 {
|
||||
check_reference(&gpu, [v as f32 / 255.0; 3]);
|
||||
}
|
||||
// Includes coloured shadows within the old LUT's very first 0..8/255 cell.
|
||||
for r in 0..=16 {
|
||||
for g in 0..=16 {
|
||||
for b in 0..=16 {
|
||||
check_reference(&gpu, [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for rgb in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.9, 0.6, 0.3]] {
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coarse_final_rgb_lut_fails_the_same_icc_shadow_reference() {
|
||||
let transform = synthetic_transform(false);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
let coarse = Lut3d::from_transform(&transform, 33).unwrap();
|
||||
let rgb = [2.0 / 255.0; 3];
|
||||
let expected = reference(rgb);
|
||||
let old = coarse.sample(rgb);
|
||||
// Even float LUT nodes fail, before the additional rounding of RGBA8 upload. This
|
||||
// guards the cause of the original bug rather than a particular observed screenshot.
|
||||
assert!(old.iter().zip(expected).all(|(a, e)| e - f64::from(*a) > 2.0 / 255.0), "old {old:?}, reference {expected:?}");
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_display_representation_accepts_u8_u16_and_float_samples() {
|
||||
let transform = synthetic_transform(false);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
for v in [0_u8, 1, 2, 7, 8, 16, 32, 128, 255] {
|
||||
check_reference(&gpu, [v as f32 / 255.0; 3]);
|
||||
}
|
||||
for v in [0_u16, 1, 2, 15, 63, 257, 514, 1028, 4096, 32768, 65535] {
|
||||
check_reference(&gpu, [v as f32 / 65535.0; 3]);
|
||||
}
|
||||
for rgb in [[0.000003, 0.00001, 0.00002], [0.00123, 0.00456, 0.00789], [0.2, 0.45, 0.8], [-0.5, 0.0, 2.0]] {
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
assert_eq!(sample_gpu(&gpu, [0.0; 3]), [0.0; 3]);
|
||||
// White differs very slightly from 1 because the synthetic matrices use ICC s15Fixed16.
|
||||
check_reference(&gpu, [1.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_curves_and_matrix_use_identity_without_losing_offsets() {
|
||||
let identity = Transform::from_pipeline(Pipeline::new(3, Vec::new()).unwrap(), TransformOptions::default()).unwrap().gpu_matrix_shaper().unwrap();
|
||||
for rgb in [[0.0; 3], [1.0; 3], [0.001, 0.2, 0.8], [-2.0, 0.5, 2.0]] {
|
||||
for (actual, input) in sample_gpu(&identity, rgb).into_iter().zip(rgb) {
|
||||
assert!((actual - input.clamp(0.0, 1.0)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
let matrix = Stage::Matrix { rows: 3, cols: 3, m: vec![0.8, 0.0, 0.0, 0.0, 0.7, 0.0, 0.0, 0.0, 0.6], offset: vec![0.01, 0.02, 0.03] };
|
||||
let transform =
|
||||
Transform::from_pipeline(Pipeline::new(3, vec![matrix]).unwrap(), TransformOptions::default()).unwrap().gpu_matrix_shaper().unwrap();
|
||||
let input = [0.1, 0.2, 0.3];
|
||||
let output = sample_gpu(&transform, input);
|
||||
for (actual, expected) in output.into_iter().zip([0.09, 0.16, 0.21]) {
|
||||
assert!((actual - expected).abs() < 1e-6, "{output:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_clut_proof_and_non_rgb_transforms_retain_fallback() {
|
||||
let identity_clut = Clut::sample(vec![2; 3], 3, |input, output| output.copy_from_slice(input)).unwrap();
|
||||
let clut = Transform::from_pipeline(Pipeline::new(3, vec![Stage::Clut(identity_clut)]).unwrap(), TransformOptions::default()).unwrap();
|
||||
assert!(clut.gpu_matrix_shaper().is_none());
|
||||
let srgb = Builtin::Srgb.profile();
|
||||
let lab = Transform::new(Builtin::LabD50.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
|
||||
assert!(lab.gpu_matrix_shaper().is_none());
|
||||
let gray = Transform::new(Builtin::GrayGamma22.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
|
||||
assert!(gray.gpu_matrix_shaper().is_none());
|
||||
let proof = Transform::proof(srgb, Builtin::DisplayP3.profile(), srgb, Intent::RelativeColorimetric, false, false).unwrap();
|
||||
assert!(proof.gpu_matrix_shaper().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_matrix_storage_is_not_exposed_to_the_gpu() {
|
||||
for (coefficients, offsets) in [(vec![2.0; 8], vec![0.0; 3]), (vec![2.0; 9], vec![0.0; 2]), (vec![f64::INFINITY; 9], vec![0.0; 3])] {
|
||||
// A matrix whose storage does not match its shape is refused when the pipeline is built.
|
||||
let Ok(pipeline) = Pipeline::new(3, vec![Stage::Matrix { rows: 3, cols: 3, m: coefficients, offset: offsets }]) else { continue };
|
||||
let transform = Transform::from_pipeline(pipeline, TransformOptions::default()).unwrap();
|
||||
assert!(transform.gpu_matrix_shaper().is_none());
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ fn hex(id: [u8; 16]) -> String {
|
||||
#[test]
|
||||
fn the_id_matches_an_independent_md5() {
|
||||
// Computed outside Rust: MD5 of the shipped file with bytes 44–47, 64–67 and 84–99 zeroed.
|
||||
assert_eq!(hex(profile_id(builtin::COATED_CMYK_ICC).unwrap()), "c6eeebc63fac1fa8be528bcac92d67a9");
|
||||
assert_eq!(hex(profile_id(builtin::COATED_CMYK_ICC).unwrap()), "ab1397490b9355815dea5668de6eb073");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user