Files
craft-libs/crates/cms/src/gamut.rs
T
eadfaf85ed craft-cms: LUT file parsing fixes and GamutCheck::mask_rgba (ports of open PhotoCraft PRs) (#860)
* 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

* craft-cms: a UTF-8 byte-order mark before a .cube or .3dl file is skipped

A LUT file saved with a UTF-8 BOM failed to parse: the mark stayed on the
first keyword. decode_text now strips it, as it already handles UTF-16.

Ported from storytold/photocraft#9776.

* craft-cms: a .cube data line with a field that is not a number is an error

A data line's fields after the first were parsed with filter_map, so
'0.25 nope 0.5 0.75' silently became the three numbers that did parse. Every
field must now parse.

Ported from storytold/photocraft#10112.

* craft-cms tests: .look data is pinned to hand-written little-endian bytes

The test built its input with the same to_le_bytes the parser mirrors, so a
wrong byte order on both sides would pass. The bytes are now written out.

Ported from storytold/photocraft#10389.

* craft-cms: a 1D .cube whose domain the 33-point bake cannot hold is refused

A 1D LUT is resampled onto a fixed 33-point grid. A steep curve over a
narrow DOMAIN_MIN/DOMAIN_MAX (or LUT_1D_INPUT_RANGE) does not survive that:
a two-node ramp over 0.24..0.26 baked 42 RGB8 levels off. The parser now
measures the baked table against the exact curve for a non-default domain
and refuses one that shifts by more than two RGB8 levels, naming the
channel. Default-domain tables and gentle curves are unchanged.

Ported from storytold/photocraft#9101.

* craft-cms: only a quote that opens a .cube TITLE protects '#'

A quote anywhere on a TITLE line made the text up to the next quote part of
the title, so 'TITLE Look # "draft"' took its comment into the title. Only
a quote at the start of the value opens a quoted title now; an unterminated
one is an error, and an unquoted title ends at '#' like any other line.

Ported from storytold/photocraft#8506 (its lutfile.rs part).

* craft-cms: GamutCheck::mask_rgba computes a whole gamut-warning mask in parallel

A caller building a gamut-warning mask made one delta_e call per pixel on
one thread. mask_rgba fills the mask (255 where alpha > 0 and the pixel is
out of gamut) in 16K-pixel chunks on the rayon pool, serial below that and
on wasm, and returns the count. The result is identical to out_of_gamut per
pixel. A check not built from an RGB source profile is refused before
anything is written, since an RGBA pixel cannot stand for a CMYK, gray or
Lab colour.

Ported from storytold/photocraft#9748.

---------

Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Co-authored-by: Max Mittelstädt <62167861+TheGalitube@users.noreply.github.com>
Co-authored-by: smittie2000 <smittie2000@gmail.com>
Co-authored-by: bfenty <1398236+bfenty@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
2026-11-11 19:19:03 -05:00

166 lines
7.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Gamut checks against a proof (output) profile: Photoshop's View › Gamut Warning.
use crate::clut::Clut;
use crate::pipeline::{Pipeline, Stage};
use crate::profile::{ColorSpace, Profile};
use crate::transform::link_stages;
use crate::{CmsError, Intent, check_len, invalid_arg};
/// Default ΔE76 threshold above which a colour counts as out of gamut.
pub const DEFAULT_THRESHOLD: f32 = 4.0;
#[cfg(not(target_arch = "wasm32"))]
const PAR_MIN_PIXELS: usize = 16 * 1024;
#[cfg(not(target_arch = "wasm32"))]
const PAR_CHUNK_PIXELS: usize = 16 * 1024;
/// Precomputed out-of-gamut test for colours of `src` against `proof`.
///
/// A colour is out of gamut when sending it to the proof device (relative colorimetric,
/// clipping) and back to Lab moves it by more than the threshold (ΔE76). The ΔE is tabulated
/// over the source device space (33³ grid for 3 channels, 17⁴ for 4, exact otherwise).
#[derive(Debug)]
pub struct GamutCheck {
inputs: usize,
source_space: ColorSpace,
grid: Option<Clut>,
exact: (Pipeline, Pipeline),
pub threshold: f32,
}
impl GamutCheck {
pub fn new(src: &Profile, proof: &Profile, threshold: f32) -> Result<GamutCheck, CmsError> {
let lab = crate::Builtin::LabD50.profile();
let direct = Pipeline::new(src.channels(), link_stages(src, lab, Intent::RelativeColorimetric, false)?)?;
let mut via = link_stages(src, proof, Intent::RelativeColorimetric, false)?;
via.push(Stage::Clamp01);
via.extend(link_stages(proof, lab, Intent::RelativeColorimetric, false)?);
let via = Pipeline::new(src.channels(), via)?;
let de = |p: &[f32], o: &mut [f32]| {
let (mut a, mut b) = ([0.0f32; 16], [0.0f32; 16]);
direct.run(p, &mut a);
via.run(p, &mut b);
let d = |i: usize, s: f32| (a[i] - b[i]) * s;
o[0] = (d(0, 100.0).powi(2) + d(1, 255.0).powi(2) + d(2, 255.0).powi(2)).sqrt();
};
let n = src.channels();
let grid = match n {
1 => Some(Clut::sample(vec![256], 1, de)?),
3 => Some(Clut::sample(vec![33; 3], 1, de)?),
4 => Some(Clut::sample(vec![17; 4], 1, de)?),
_ => None,
};
Ok(GamutCheck { inputs: n, source_space: src.color_space, grid, exact: (direct, via), threshold })
}
pub fn inputs(&self) -> usize {
self.inputs
}
/// ΔE76 between the colour (at least `inputs` normalised values) and its proof round trip.
pub fn delta_e(&self, px: &[f32]) -> Result<f32, CmsError> {
check_len("gamut check input", px.len(), self.inputs)?;
let mut o = [0.0f32; 1];
match &self.grid {
Some(g) => {
let mut c = [0.0f32; 16];
for (d, s) in c.iter_mut().zip(&px[..self.inputs]) {
*d = s.clamp(0.0, 1.0);
}
g.interp(&c[..self.inputs], &mut o);
}
None => {
let (mut a, mut b) = ([0.0f32; 16], [0.0f32; 16]);
self.exact.0.run(px, &mut a);
self.exact.1.run(px, &mut b);
o[0] = ((a[0] - b[0]) * 100.0).hypot((a[1] - b[1]) * 255.0).hypot((a[2] - b[2]) * 255.0);
}
}
Ok(o[0])
}
pub fn out_of_gamut(&self, px: &[f32]) -> Result<bool, CmsError> {
Ok(self.delta_e(px)? > self.threshold)
}
/// Out-of-gamut mask for RGBA pixels: `255` where the pixel has alpha > 0 and its RGB
/// channels are out of gamut, `0` elsewhere. Returns the number of flagged pixels. Only
/// `min(px.len(), mask.len())` pixels are processed. Runs on the rayon pool (not on wasm);
/// the result is identical to calling [`Self::out_of_gamut`] per pixel.
///
/// # Errors
/// [`CmsError::InvalidArgument`] when the check was not built from an RGB source profile: an
/// RGBA pixel carries only RGB colour channels, so it cannot be tested against a CMYK, gray
/// or Lab source. Nothing is written to `mask` in that case.
pub fn mask_rgba(&self, px: &[[f32; 4]], mask: &mut [u8]) -> Result<usize, CmsError> {
if self.source_space != ColorSpace::Rgb || self.inputs != 3 {
return Err(invalid_arg(format!("gamut mask needs an RGB source profile, got {:?} with {} channels", self.source_space, self.inputs)));
}
let n = px.len().min(mask.len());
let (Some(px), Some(mask)) = (px.get(..n), mask.get_mut(..n)) else { return Ok(0) };
let run = |p: &[[f32; 4]], m: &mut [u8]| {
let mut count = 0;
for (p, m) in p.iter().zip(m.iter_mut()) {
// The source has three channels (checked above), so the check cannot fail.
let hit = p[3] > 0.0 && self.out_of_gamut(&[p[0], p[1], p[2]]).unwrap_or(false);
*m = if hit { 255 } else { 0 };
count += usize::from(hit);
}
count
};
#[cfg(not(target_arch = "wasm32"))]
if n >= PAR_MIN_PIXELS {
use rayon::prelude::*;
return Ok(px.par_chunks(PAR_CHUNK_PIXELS).zip(mask.par_chunks_mut(PAR_CHUNK_PIXELS)).map(|(p, m)| run(p, m)).sum());
}
Ok(run(px, mask))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Builtin;
fn image(n: usize) -> Vec<[f32; 4]> {
let mut s = 0x9E37_79B9u32;
let mut rnd = move || {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
(s >> 8) as f32 / (1u32 << 24) as f32
};
// Includes out-of-range values and zero alpha.
(0..n).map(|i| [rnd() * 1.2 - 0.1, rnd(), rnd(), if i % 7 == 0 { 0.0 } else { rnd() }]).collect()
}
#[test]
fn mask_rgba_matches_per_pixel_check() {
let src = Builtin::Srgb.profile();
// Sizes straddle the parallel threshold and the chunk size, odd ones included.
for (proof, threshold) in [(Builtin::CoatedCmyk, 4.0), (Builtin::CoatedCmyk, 1.5), (Builtin::GrayGamma22, 4.0), (Builtin::DisplayP3, 0.5)] {
let check = GamutCheck::new(src, proof.profile(), threshold).unwrap();
for n in [0, 1, 1023, 16 * 1024 - 1, 16 * 1024, 16 * 1024 + 1, 50_001] {
let px = image(n);
let want: Vec<u8> = px.iter().map(|p| if p[3] > 0.0 && check.out_of_gamut(&p[..3]).unwrap() { 255 } else { 0 }).collect();
let mut got = vec![7u8; n];
let count = check.mask_rgba(&px, &mut got).unwrap();
assert_eq!(got, want, "{proof:?} threshold {threshold} n {n}");
assert_eq!(count, want.iter().filter(|&&m| m == 255).count());
}
}
}
#[test]
fn mask_rgba_rejects_non_rgb_source() {
// A CMYK (4-channel), gray (1-channel) and Lab (3-channel, not RGB) source cannot be tested from RGBA pixels.
for src in [Builtin::CoatedCmyk, Builtin::GrayGamma22, Builtin::LabD50] {
let check = GamutCheck::new(src.profile(), Builtin::Srgb.profile(), 4.0).unwrap();
let px = image(64);
let mut mask = vec![9u8; 64];
assert!(matches!(check.mask_rgba(&px, &mut mask), Err(CmsError::InvalidArgument(_))), "{src:?}");
assert!(mask.iter().all(|&m| m == 9), "mask must stay untouched");
}
}
}