craft-cms: return errors instead of panicking on caller input

The crate denies panics, but asserts and slice indexing were reachable
from arguments: pixel strides, buffers that are not whole pixels or a
destination shorter than the source, single-colour slices shorter than
the channel count, CLUT and pipeline dimensions, and LUT sizes.

Every such check now returns CmsError::InvalidArgument. Buffer
conversions validate once per call, before any pixel is written, so the
per-pixel loops carry no extra checks; internal callers that already
guarantee lengths use crate-private unchecked evaluation.

Pipeline, Clut and Lut3d keep their fields private (read through
accessors) so a validated value cannot be changed into one that panics.
Pipeline::new checks matrix shapes and channel counts; Clut::new and
Clut::sample check grid, outputs and the table size limit.

API changes (unreleased crate):
- Transform::eval, eval_fast, convert_u8/u16/f32, apply,
  convert_bytes_many and from_pipeline return Result.
- Pipeline::new, extend and eval, Clut::new, sample, eval,
  eval_trilinear and eval_multilinear return Result.
- GamutCheck::delta_e and out_of_gamut, Lut3d::from_transform,
  LutFile::identity and from_fn, synth::cmyk_profile and coated_cmyk
  return Result.
- Pipeline, Clut and Lut3d fields are read through methods.
This commit is contained in:
aiongg
2026-10-13 01:33:07 +00:00
parent f97ed953f4
commit d0107d1130
16 changed files with 551 additions and 236 deletions
+8 -3
View File
@@ -18,14 +18,19 @@ use craft_cms::{Builtin, Intent, Profile, Transform};
let src = Profile::parse(&icc_bytes)?; // v2/v4: matrix/TRC, gray TRC, mft1/mft2/mAB/mBA
let dst = Builtin::CoatedCmyk.profile();
let t = Transform::new(&src, dst, Intent::RelativeColorimetric, /*bpc*/ true)?;
t.convert_u8(&rgba8, 4, &mut cmyka8, 5, /*copy alpha*/ true); // also convert_u16 / convert_f32 / apply (in place)
t.convert_u8(&rgba8, 4, &mut cmyka8, 5, /*copy alpha*/ true)?; // also convert_u16 / convert_f32 / apply (in place)
let mut out = [0.0; 4];
t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colour
t.eval(&[1.0, 0.0, 0.0], &mut out)?; // exact single colour
```
Calls never panic on caller input: strides below the channel count, buffers that are not
whole pixels, a destination with fewer pixels than the source, slices shorter than the
channel count and out-of-range table or LUT dimensions return `CmsError::InvalidArgument`.
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.
* `transform::cached(src, dst, opts)`: process-wide transform cache.
* `lutfile::parse(name, bytes)` / `lutfile::write_cube(&lut)`: `.cube`, `.3dl` and `.look` LUT
files; `lutfile::builtin(id)` returns generated looks (`lutfile::BUILTIN`).
+29 -6
View File
@@ -323,12 +323,35 @@ fn build(b: Builtin) -> Profile {
Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)),
Builtin::SGray => gray(b.description(), srgb_trc()),
Builtin::LabD50 => lab(),
// The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test); rebuild
// them rather than crash if they ever fail to parse.
Builtin::CoatedCmyk => Profile::parse(COATED_CMYK_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()),
// The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test).
Builtin::CoatedCmyk => shipped_cmyk(COATED_CMYK_ICC, b),
// The fit needs the measurement data, which only the tests have; the `fogra39` tests
// check that the shipped bytes parse, so the synthetic coated profile is a fallback
// that does not happen.
Builtin::CoatedFogra39 => Profile::parse(COATED_FOGRA39_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()),
// check that the shipped bytes parse.
Builtin::CoatedFogra39 => shipped_cmyk(COATED_FOGRA39_ICC, b),
}
}
/// A shipped CMYK profile. Should its bytes ever fail to parse, the synthetic coated profile
/// is generated instead, and failing that a CMYK profile without tables stands in (transforms
/// refuse it with an error) rather than crash.
fn shipped_cmyk(bytes: &[u8], b: Builtin) -> Profile {
Profile::parse(bytes).or_else(|_| crate::synth::coated_cmyk()).unwrap_or_else(|_| Profile {
version: (4, 0x30),
class: ProfileClass::Output,
color_space: ColorSpace::Cmyk,
pcs: Pcs::Lab,
rendering_intent: Intent::Perceptual,
description: b.description().into(),
copyright: COPYRIGHT.into(),
white_point: math::d50_quantized(),
black_point: None,
chad: None,
matrix: None,
trc: None,
gray_trc: None,
a2b: [None, None, None],
b2a: [None, None, None],
bytes: None,
hash: Default::default(),
})
}
+99 -35
View File
@@ -5,48 +5,90 @@
//! Kasson et al. 1995); 4-input tables interpolate linearly along the first input between
//! two tetrahedral evaluations; other input counts use multilinear interpolation.
use crate::{CmsError, check_len, invalid_arg};
/// Maximum number of input or output channels of a CLUT.
pub const MAX_CHANNELS: usize = 15;
/// Largest number of stored values (nodes × outputs) a table may hold.
pub const MAX_VALUES: usize = 16 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq)]
pub struct Clut {
pub inputs: usize,
pub outputs: usize,
/// Grid points per input dimension (each ≥ 2, or 1 for a degenerate dimension).
pub grid: Vec<usize>,
/// Normalised node values, `outputs` per node.
pub data: Vec<f32>,
pub(crate) inputs: usize,
pub(crate) outputs: usize,
pub(crate) grid: Vec<usize>,
pub(crate) data: Vec<f32>,
strides: Vec<usize>,
}
impl Clut {
/// Builds a table from node data. Panics if `data` has the wrong length.
pub fn new(grid: Vec<usize>, outputs: usize, data: Vec<f32>) -> Self {
/// Builds a table from node data: `grid` holds the points per input dimension (1 to
/// [`MAX_CHANNELS`] dimensions, each ≥ 2, or 1 for a degenerate dimension), `data` holds
/// `outputs` values per node. Refuses inconsistent dimensions with
/// [`CmsError::InvalidArgument`].
pub fn new(grid: Vec<usize>, outputs: usize, data: Vec<f32>) -> Result<Self, CmsError> {
let total = Self::check_dims(&grid, outputs)?;
if data.len() != total {
return Err(invalid_arg(format!("CLUT data has {} values, the grid needs {total}", data.len())));
}
Ok(Self::from_checked(grid, outputs, data))
}
/// Number of values a `grid` × `outputs` table stores, or why it is refused.
fn check_dims(grid: &[usize], outputs: usize) -> Result<usize, CmsError> {
if !(1..=MAX_CHANNELS).contains(&grid.len()) || !(1..=MAX_CHANNELS).contains(&outputs) {
return Err(invalid_arg(format!("CLUT with {} inputs and {outputs} outputs (1 to {MAX_CHANNELS} each)", grid.len())));
}
if grid.contains(&0) {
return Err(invalid_arg("CLUT grid with zero points in a dimension"));
}
grid.iter()
.try_fold(outputs, |n, g| n.checked_mul(*g))
.filter(|n| *n <= MAX_VALUES)
.ok_or_else(|| invalid_arg(format!("CLUT grid {grid:?} × {outputs} exceeds {MAX_VALUES} values")))
}
fn from_checked(grid: Vec<usize>, outputs: usize, data: Vec<f32>) -> Self {
let inputs = grid.len();
assert!((1..=MAX_CHANNELS).contains(&inputs) && (1..=MAX_CHANNELS).contains(&outputs), "bad CLUT dimensions");
let mut strides = vec![0usize; inputs];
let mut s = outputs;
for i in (0..inputs).rev() {
strides[i] = s;
s *= grid[i].max(1);
s *= grid[i];
}
assert_eq!(data.len(), s, "CLUT data length");
Clut { inputs, outputs, grid, data, strides }
}
pub fn inputs(&self) -> usize {
self.inputs
}
pub fn outputs(&self) -> usize {
self.outputs
}
/// Grid points per input dimension.
pub fn grid(&self) -> &[usize] {
&self.grid
}
/// Normalised node values, `outputs` per node, first input varying slowest.
pub fn data(&self) -> &[f32] {
&self.data
}
/// Number of nodes.
pub fn nodes(&self) -> usize {
self.data.len() / self.outputs
}
/// Builds a table by sampling `f` at every node (inputs normalised to `[0, 1]`).
pub fn sample(grid: Vec<usize>, outputs: usize, mut f: impl FnMut(&[f32], &mut [f32])) -> Self {
/// Builds a table by sampling `f` at every node (inputs normalised to `[0, 1]`); the
/// dimensions are checked as in [`Clut::new`].
pub fn sample(grid: Vec<usize>, outputs: usize, mut f: impl FnMut(&[f32], &mut [f32])) -> Result<Self, CmsError> {
let total = Self::check_dims(&grid, outputs)?;
let inputs = grid.len();
let total: usize = grid.iter().product();
let mut data = vec![0.0f32; total * outputs];
let mut data = vec![0.0f32; total];
let mut idx = vec![0usize; inputs];
let mut inp = vec![0.0f32; inputs];
for node in 0..total {
for (node, out) in data.chunks_exact_mut(outputs).enumerate() {
let mut rem = node;
for d in (0..inputs).rev() {
idx[d] = rem % grid[d];
@@ -55,9 +97,9 @@ impl Clut {
for d in 0..inputs {
inp[d] = if grid[d] > 1 { idx[d] as f32 / (grid[d] - 1) as f32 } else { 0.0 };
}
f(&inp, &mut data[node * outputs..(node + 1) * outputs]);
f(&inp, out);
}
Clut::new(grid, outputs, data)
Ok(Self::from_checked(grid, outputs, data))
}
#[inline]
@@ -71,9 +113,22 @@ impl Clut {
(i * self.strides[d], p - i as f32, self.strides[d])
}
/// Interpolates at `input` (`inputs` values) into `out` (`outputs` values).
/// Interpolates at `input` (at least `inputs` values) into `out` (at least `outputs`
/// values).
pub fn eval(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> {
self.check_io(input, out)?;
self.interp(input, out);
Ok(())
}
fn check_io(&self, input: &[f32], out: &[f32]) -> Result<(), CmsError> {
check_len("CLUT input", input.len(), self.inputs)?;
check_len("CLUT output", out.len(), self.outputs)
}
/// [`Clut::eval`] for callers that guarantee the slice lengths.
#[inline]
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
pub(crate) fn interp(&self, input: &[f32], out: &mut [f32]) {
match self.inputs {
1 => self.eval1(input[0], out),
3 => {
@@ -100,7 +155,7 @@ impl Clut {
out[..self.outputs].copy_from_slice(&lo[..self.outputs]);
}
}
_ => self.eval_multilinear(input, out),
_ => self.multilinear(input, out),
}
}
@@ -153,13 +208,21 @@ impl Clut {
}
/// Trilinear interpolation (3 inputs), kept as a reference for accuracy comparisons.
pub fn eval_trilinear(&self, input: &[f32], out: &mut [f32]) {
assert_eq!(self.inputs, 3);
self.eval_multilinear(input, out);
pub fn eval_trilinear(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> {
if self.inputs != 3 {
return Err(invalid_arg(format!("trilinear interpolation of a {}-input CLUT", self.inputs)));
}
self.eval_multilinear(input, out)
}
/// Multilinear interpolation over all 2^n cube corners.
pub fn eval_multilinear(&self, input: &[f32], out: &mut [f32]) {
pub fn eval_multilinear(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> {
self.check_io(input, out)?;
self.multilinear(input, out);
Ok(())
}
fn multilinear(&self, input: &[f32], out: &mut [f32]) {
let n = self.inputs;
let mut base = 0usize;
let mut r = [0.0f32; MAX_CHANNELS];
@@ -205,28 +268,28 @@ mod tests {
#[test]
fn exact_on_nodes_and_linear_functions() {
let c = Clut::sample(vec![5, 5, 5], 3, f3);
let c = Clut::sample(vec![5, 5, 5], 3, f3).unwrap();
let mut o = [0.0; 3];
c.eval(&[0.25, 0.5, 0.75], &mut o);
c.eval(&[0.25, 0.5, 0.75], &mut o).unwrap();
let mut w = [0.0; 3];
f3(&[0.25, 0.5, 0.75], &mut w);
for i in 0..3 {
assert!((o[i] - w[i]).abs() < 1e-6);
}
// Linear function is reproduced exactly anywhere.
c.eval(&[0.13, 0.77, 0.41], &mut o);
c.eval(&[0.13, 0.77, 0.41], &mut o).unwrap();
assert!((o[0] - (0.13 * 0.5 + 0.77 * 0.3 + 0.41 * 0.2)).abs() < 1e-6);
}
#[test]
fn four_d_and_multilinear_agree_on_linear() {
let f = |p: &[f32], o: &mut [f32]| o[0] = 0.1 * p[0] + 0.2 * p[1] + 0.3 * p[2] + 0.4 * p[3];
let c = Clut::sample(vec![3, 4, 5, 6], 1, f);
let c = Clut::sample(vec![3, 4, 5, 6], 1, f).unwrap();
let mut a = [0.0];
let mut b = [0.0];
for p in [[0.1f32, 0.9, 0.3, 0.7], [1.0, 0.0, 0.5, 0.5], [0.33, 0.66, 0.99, 0.01]] {
c.eval(&p, &mut a);
c.eval_multilinear(&p, &mut b);
c.eval(&p, &mut a).unwrap();
c.eval_multilinear(&p, &mut b).unwrap();
let want = 0.1 * p[0] + 0.2 * p[1] + 0.3 * p[2] + 0.4 * p[3];
assert!((a[0] - want).abs() < 1e-5 && (b[0] - want).abs() < 1e-5);
}
@@ -237,12 +300,13 @@ mod tests {
let c = Clut::sample(vec![3], 2, |p, o| {
o[0] = p[0];
o[1] = 1.0 - p[0];
});
})
.unwrap();
let mut o = [0.0; 2];
c.eval(&[0.25], &mut o);
c.eval(&[0.25], &mut o).unwrap();
assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.75).abs() < 1e-6);
let c = Clut::sample(vec![2, 2], 1, |p, o| o[0] = p[0] + p[1]);
c.eval(&[0.5, 0.25], &mut o);
let c = Clut::sample(vec![2, 2], 1, |p, o| o[0] = p[0] + p[1]).unwrap();
c.eval(&[0.5, 0.25], &mut o).unwrap();
assert!((o[0] - 0.75).abs() < 1e-6);
}
}
+3 -2
View File
@@ -94,9 +94,10 @@ impl Curve {
}
}
/// Samples `n` evenly spaced points over `[0, 1]`.
/// Samples `n` evenly spaced points over `[0, 1]` (one sample is the value at 0).
pub fn sample(&self, n: usize) -> Vec<f32> {
(0..n).map(|i| self.eval64(i as f64 / (n - 1) as f64) as f32).collect()
let step = n.saturating_sub(1).max(1) as f64;
(0..n).map(|i| self.eval64(i as f64 / step) as f32).collect()
}
/// `true` when the curve never decreases over `[0, 1]`.
+17 -16
View File
@@ -4,7 +4,7 @@ use crate::clut::Clut;
use crate::pipeline::{Pipeline, Stage};
use crate::profile::Profile;
use crate::transform::link_stages;
use crate::{CmsError, Intent};
use crate::{CmsError, Intent, check_len};
/// Default ΔE76 threshold above which a colour counts as out of gamut.
pub const DEFAULT_THRESHOLD: f32 = 4.0;
@@ -25,23 +25,23 @@ pub struct GamutCheck {
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 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 via = Pipeline::new(src.channels(), via)?;
let de = |p: &[f32], o: &mut [f32]| {
let (mut a, mut b) = ([0.0f32; 16], [0.0f32; 16]);
direct.eval(p, &mut a);
via.eval(p, &mut b);
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)),
1 => Some(Clut::sample(vec![256], 1, de)?),
3 => Some(Clut::sample(vec![33; 3], 1, de)?),
4 => Some(Clut::sample(vec![17; 4], 1, de)?),
_ => None,
};
Ok(GamutCheck { inputs: n, grid, exact: (direct, via), threshold })
@@ -51,8 +51,9 @@ impl GamutCheck {
self.inputs
}
/// ΔE76 between the colour and its proof round trip.
pub fn delta_e(&self, px: &[f32]) -> f32 {
/// Δ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) => {
@@ -60,19 +61,19 @@ impl GamutCheck {
for (d, s) in c.iter_mut().zip(&px[..self.inputs]) {
*d = s.clamp(0.0, 1.0);
}
g.eval(&c[..self.inputs], &mut o);
g.interp(&c[..self.inputs], &mut o);
}
None => {
let (mut a, mut b) = ([0.0f32; 16], [0.0f32; 16]);
self.exact.0.eval(px, &mut a);
self.exact.1.eval(px, &mut b);
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);
}
}
o[0]
Ok(o[0])
}
pub fn out_of_gamut(&self, px: &[f32]) -> bool {
self.delta_e(px) > self.threshold
pub fn out_of_gamut(&self, px: &[f32]) -> Result<bool, CmsError> {
Ok(self.delta_e(px)? > self.threshold)
}
}
+15 -1
View File
@@ -83,7 +83,7 @@ impl Intent {
}
}
/// Errors from parsing profiles or building transforms.
/// Errors from parsing profiles, building transforms, or arguments that do not fit an operation.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CmsError {
Truncated,
@@ -91,6 +91,10 @@ pub enum CmsError {
BadSignature,
Invalid(String),
Unsupported(String),
/// A caller argument does not fit the operation: a pixel stride below the channel count, a
/// buffer that is not whole pixels or is too short, a slice shorter than the channel count,
/// or table dimensions out of range.
InvalidArgument(String),
}
impl std::fmt::Display for CmsError {
@@ -100,8 +104,18 @@ impl std::fmt::Display for CmsError {
CmsError::BadSignature => write!(f, "not an ICC profile (missing 'acsp' signature)"),
CmsError::Invalid(m) => write!(f, "invalid ICC profile: {m}"),
CmsError::Unsupported(m) => write!(f, "unsupported ICC feature: {m}"),
CmsError::InvalidArgument(m) => write!(f, "invalid argument: {m}"),
}
}
}
impl std::error::Error for CmsError {}
pub(crate) fn invalid_arg(m: impl Into<String>) -> CmsError {
CmsError::InvalidArgument(m.into())
}
/// Checks that `slice` holds at least `n` values (single-colour evaluation).
pub(crate) fn check_len(what: &str, len: usize, n: usize) -> Result<(), CmsError> {
if len < n { Err(invalid_arg(format!("{what} has {len} values, {n} needed"))) } else { Ok(()) }
}
+25 -9
View File
@@ -1,34 +1,50 @@
//! 3D lookup tables for applying a display transform on the GPU.
use crate::Transform;
use crate::lutfile::MAX_SIZE;
use crate::{CmsError, Transform, invalid_arg};
/// An `size³` RGBA lookup table: entry `r + g·size + b·size²` holds the output for input
/// `(r, g, b) / (size − 1)`, alpha 1. This is the memory order of a 3D texture whose x axis is
/// red, so it uploads directly (sample at `(c·(size−1) + 0.5) / size` with linear filtering).
#[derive(Clone, Debug, PartialEq)]
pub struct Lut3d {
pub size: usize,
pub data: Vec<[f32; 4]>,
size: usize,
data: Vec<[f32; 4]>,
}
impl Lut3d {
/// Samples a 3-input transform (1-output transforms are replicated to gray).
pub fn from_transform(t: &Transform, size: usize) -> Lut3d {
assert!(t.inputs() == 3 && (t.outputs() == 3 || t.outputs() == 1), "display LUTs need a 3→3 or 3→1 transform");
assert!(size >= 2, "LUT size");
/// Samples a 3-input transform (1-output transforms are replicated to gray) on a `size`³
/// grid, `size` from 2 to [`MAX_SIZE`].
pub fn from_transform(t: &Transform, size: usize) -> Result<Lut3d, CmsError> {
if t.inputs() != 3 || !(t.outputs() == 3 || t.outputs() == 1) {
return Err(invalid_arg(format!("display LUTs need a 3→3 or 3→1 transform, not {}→{}", t.inputs(), t.outputs())));
}
if !(2..=MAX_SIZE).contains(&size) {
return Err(invalid_arg(format!("3D LUT size {size} (2 to {MAX_SIZE})")));
}
let mut data = vec![[0.0f32, 0.0, 0.0, 1.0]; size * size * size];
let s = (size - 1) as f32;
let mut out = [0.0f32; 16];
for b in 0..size {
for g in 0..size {
for r in 0..size {
t.eval(&[r as f32 / s, g as f32 / s, b as f32 / s], &mut out);
t.eval(&[r as f32 / s, g as f32 / s, b as f32 / s], &mut out)?;
let v = if t.outputs() == 1 { [out[0]; 3] } else { [out[0], out[1], out[2]] };
data[r + g * size + b * size * size] = [v[0], v[1], v[2], 1.0];
}
}
}
Lut3d { size, data }
Ok(Lut3d { size, data })
}
/// Edge length.
pub fn size(&self) -> usize {
self.size
}
/// `size`³ RGBA entries, red fastest.
pub fn data(&self) -> &[[f32; 4]] {
&self.data
}
/// Trilinear lookup (what GPU filtering computes).
+13 -12
View File
@@ -50,15 +50,16 @@ fn checked_data_len(size: usize) -> Result<(usize, usize), LutError> {
}
impl LutFile {
/// The identity table of edge `size`.
pub fn identity(size: usize) -> Self {
/// The identity table of edge `size` (2 to [`MAX_SIZE`]).
pub fn identity(size: usize) -> Result<Self, LutError> {
Self::from_fn("Identity", size, |c| c)
}
/// Samples `f` on an `size`³ grid.
pub fn from_fn(title: &str, size: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> Self {
let m = (size.max(2) - 1) as f32;
let mut data = Vec::with_capacity(size * size * size * 3);
/// Samples `f` on an `size`³ grid, `size` from 2 to [`MAX_SIZE`].
pub fn from_fn(title: &str, size: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> Result<Self, LutError> {
let (_, data_len) = checked_data_len(size)?;
let m = (size - 1) as f32;
let mut data = Vec::with_capacity(data_len);
for b in 0..size {
for g in 0..size {
for r in 0..size {
@@ -67,7 +68,7 @@ impl LutFile {
}
}
}
LutFile { title: title.to_string(), size, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }
Ok(LutFile { title: title.to_string(), size, data, domain_min: [0.0; 3], domain_max: [1.0; 3] })
}
/// Whether the table expects plain 0..1 inputs (the common case; Photoshop's default).
@@ -164,7 +165,7 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
let f = x - i as f32;
rows[i * 3 + ch] * (1.0 - f) + rows[(i + 1) * 3 + ch] * f
};
let mut l = LutFile::from_fn(&title, 33, |c| [curve(0, c[0]), curve(1, c[1]), curve(2, c[2])]);
let mut l = LutFile::from_fn(&title, 33, |c| [curve(0, c[0]), curve(1, c[1]), curve(2, c[2])])?;
l.title = title;
return l.check();
}
@@ -294,7 +295,7 @@ pub fn builtin(id: &str) -> Option<LutFile> {
"monoContrast" => Box::new(|c: [f32; 3]| [smooth(smooth(luma(c))); 3]),
_ /* crossProcess */ => Box::new(|c: [f32; 3]| [smooth(c[0]) * 1.05, c[1] * 1.1 - 0.03, c[2] * 0.7 + 0.15]),
};
Some(LutFile::from_fn(label, 33, f))
LutFile::from_fn(label, 33, f).ok()
}
#[cfg(test)]
@@ -362,7 +363,7 @@ DOMAIN_MAX 0.5 1 1
#[test]
fn cube_roundtrip_and_identity() {
let id = LutFile::identity(5);
let id = LutFile::identity(5).unwrap();
let text = write_cube(&LutFile { title: "Id".into(), ..id.clone() });
let back = parse_cube(&text).unwrap();
assert_eq!(back.size, 5);
@@ -408,12 +409,12 @@ DOMAIN_MAX 0.5 1 1
}
let l = parse_3dl(&text).unwrap();
assert_eq!(l.size, 2);
assert_eq!(l.data, LutFile::identity(2).data);
assert_eq!(l.data, LutFile::identity(2).unwrap().data);
}
#[test]
fn look_hex_floats() {
let id = LutFile::identity(2);
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\"?><look><LUT><size>\"2\"</size><data>\"{hex}\"</data></LUT></look>");
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
+67 -17
View File
@@ -4,7 +4,7 @@
use crate::clut::Clut;
use crate::curve::Curve;
use crate::math;
use crate::{CmsError, check_len, invalid_arg, math};
/// Largest channel count flowing through a pipeline.
pub const MAX: usize = 16;
@@ -54,6 +54,25 @@ impl Stage {
}
}
/// Checks that the stage can run on `n` channels: matrix shapes match their data and
/// channel counts stay within [`MAX`].
fn check(&self, n: usize) -> Result<(), CmsError> {
if let Stage::Matrix { rows, cols, m, offset } = self
&& (!(1..=MAX).contains(rows) || !(1..=MAX).contains(cols) || m.len() != rows * cols || offset.len() != *rows)
{
return Err(invalid_arg(format!(
"{rows}×{cols} matrix stage with {} coefficients and {} offsets (1 to {MAX} rows and columns)",
m.len(),
offset.len()
)));
}
let out = self.outputs(n);
if !(1..=MAX).contains(&out) {
return Err(invalid_arg(format!("pipeline stage with {out} outputs (1 to {MAX})")));
}
Ok(())
}
pub fn matrix3(m: &math::Mat3) -> Stage {
Stage::Matrix { rows: 3, cols: 3, m: m.iter().flatten().copied().collect(), offset: vec![0.0; 3] }
}
@@ -89,7 +108,7 @@ impl Stage {
let mut o = [0.0f32; MAX];
let mut inp = [0.0f32; MAX];
inp[..n].copy_from_slice(&v[..n]);
c.eval(&inp[..c.inputs], &mut o[..c.outputs]);
c.interp(&inp[..c.inputs], &mut o[..c.outputs]);
v[..c.outputs].copy_from_slice(&o[..c.outputs]);
c.outputs
}
@@ -152,28 +171,59 @@ impl Stage {
/// A sequence of stages with fixed input and output channel counts.
#[derive(Clone, Debug, PartialEq)]
pub struct Pipeline {
pub inputs: usize,
pub outputs: usize,
pub stages: Vec<Stage>,
inputs: usize,
outputs: usize,
stages: Vec<Stage>,
}
impl Pipeline {
pub fn new(inputs: usize, stages: Vec<Stage>) -> Self {
let outputs = stages.iter().fold(inputs, |n, s| s.outputs(n));
Pipeline { inputs, outputs, stages }
}
/// Appends stages.
pub fn extend(&mut self, stages: impl IntoIterator<Item = Stage>) {
for s in stages {
self.outputs = s.outputs(self.outputs);
self.stages.push(s);
/// A pipeline over `inputs` channels (1 to [`MAX`]). Refuses stages that cannot run on the
/// channels reaching them with [`CmsError::InvalidArgument`].
pub fn new(inputs: usize, stages: Vec<Stage>) -> Result<Self, CmsError> {
if !(1..=MAX).contains(&inputs) {
return Err(invalid_arg(format!("pipeline with {inputs} inputs (1 to {MAX})")));
}
let mut p = Pipeline { inputs, outputs: inputs, stages: Vec::with_capacity(stages.len()) };
p.extend(stages)?;
Ok(p)
}
/// Evaluates one pixel: `input` has `inputs` values, `out` receives `outputs` values.
pub fn inputs(&self) -> usize {
self.inputs
}
pub fn outputs(&self) -> usize {
self.outputs
}
pub fn stages(&self) -> &[Stage] {
&self.stages
}
/// Appends stages, checked as in [`Pipeline::new`]; on error the pipeline is unchanged.
pub fn extend(&mut self, stages: impl IntoIterator<Item = Stage>) -> Result<(), CmsError> {
let mut n = self.outputs;
let mut added = Vec::new();
for s in stages {
s.check(n)?;
n = s.outputs(n);
added.push(s);
}
self.stages.extend(added);
self.outputs = n;
Ok(())
}
/// Evaluates one pixel: `input` has at least `inputs` values, `out` receives `outputs`
/// values.
pub fn eval(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> {
check_len("pipeline input", input.len(), self.inputs)?;
check_len("pipeline output", out.len(), self.outputs)?;
self.run(input, out);
Ok(())
}
/// [`Pipeline::eval`] for callers that guarantee the slice lengths.
#[inline]
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
pub(crate) fn run(&self, input: &[f32], out: &mut [f32]) {
let mut v = [0.0f32; MAX];
v[..self.inputs].copy_from_slice(&input[..self.inputs]);
let mut n = self.inputs;
+6 -6
View File
@@ -252,9 +252,9 @@ impl Profile {
k /= steps;
}
let (mut la, mut lb) = ([0f32; 3], [0f32; 3]);
a.eval(&input[..n], &mut la);
b.eval(&input[..n], &mut lb);
math::delta_e76(decode(la), decode(lb)) <= SAME_COLORS_MAX_DELTA_E
a.eval(&input[..n], &mut la).is_ok()
&& b.eval(&input[..n], &mut lb).is_ok()
&& math::delta_e76(decode(la), decode(lb)) <= SAME_COLORS_MAX_DELTA_E
})
}
@@ -438,7 +438,7 @@ fn sig(s: &[u8; 4]) -> u32 {
}
/// Upper bound on CLUT nodes × outputs we accept, to reject hostile files.
const MAX_CLUT_VALUES: usize = 16 * 1024 * 1024;
const MAX_CLUT_VALUES: usize = crate::clut::MAX_VALUES;
fn parse_profile(bytes: &[u8]) -> Result<Profile, CmsError> {
let r = Reader { b: bytes };
@@ -674,7 +674,7 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
stages.push(Stage::matrix3(&m));
}
stages.push(Stage::Curves(in_curves));
stages.push(Stage::Clut(Clut::new(vec![g; i], o, data)));
stages.push(Stage::Clut(Clut::new(vec![g; i], o, data)?));
stages.push(Stage::Curves(out_curves));
Ok(Lut { kind: if wide { LutKind::Lut16 } else { LutKind::Lut8 }, inputs: i, outputs: o, stages })
}
@@ -712,7 +712,7 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
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)))
Ok(Stage::Clut(Clut::new(grid, cout, data)?))
};
let mut stages = Vec::new();
if ty == b"mAB " {
+31 -21
View File
@@ -31,7 +31,7 @@ use crate::curve::Curve;
use crate::math;
use crate::pipeline::Stage;
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
use crate::{CmsError, Intent};
use crate::{CmsError, Intent, invalid_arg};
pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
@@ -168,7 +168,7 @@ struct TableForward(Clut);
impl Forward for TableForward {
fn lab(&self, cmyk: [f64; 4]) -> [f64; 3] {
let mut o = [0.0f32; 3];
self.0.eval(&cmyk.map(|v| v.clamp(0.0, 1.0) as f32), &mut o);
self.0.interp(&cmyk.map(|v| v.clamp(0.0, 1.0) as f32), &mut o);
decode_v2(o.map(f64::from))
}
}
@@ -404,7 +404,7 @@ fn par_map<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
(0..n).map(f).collect()
}
fn b2a_table<F: Forward + ?Sized>(model: &F, p: &CmykParams, gbd: Option<&Gbd>) -> Clut {
fn b2a_table<F: Forward + ?Sized>(model: &F, p: &CmykParams, gbd: Option<&Gbd>) -> Result<Clut, CmsError> {
let n = p.grid_b2a.max(2);
let nodes = n * n * n;
let vals = par_map(n * n, |row| {
@@ -429,6 +429,17 @@ fn b2a_table<F: Forward + ?Sized>(model: &F, p: &CmykParams, gbd: Option<&Gbd>)
Clut::new(vec![n; 3], 4, data)
}
/// Refuses grids below 2 nodes or above what a [`Clut`] holds, before anything is sampled.
fn check_grids(p: &CmykParams) -> Result<(), CmsError> {
let fits = |grid: usize, dims: u32, outputs: usize| {
grid >= 2 && grid.checked_pow(dims).and_then(|n| n.checked_mul(outputs)).is_some_and(|n| n <= crate::clut::MAX_VALUES)
};
if !fits(p.grid_a2b, 4, 3) || !fits(p.grid_b2a, 3, 4) {
return Err(invalid_arg(format!("CMYK profile grids {}⁴/{}³ need at least 2 nodes and must fit a CLUT", p.grid_a2b, p.grid_b2a)));
}
Ok(())
}
fn lut16(clut: Clut) -> Lut {
let (i, o) = (clut.inputs, clut.outputs);
Lut {
@@ -439,16 +450,17 @@ fn lut16(clut: Clut) -> Lut {
}
}
/// Generates a synthetic CMYK profile.
pub fn cmyk_profile(p: &CmykParams) -> Profile {
/// Generates a synthetic CMYK profile. Grids are refused as in [`cmyk_profile_from_model`].
pub fn cmyk_profile(p: &CmykParams) -> Result<Profile, CmsError> {
check_grids(p)?;
let model = CmykModel::coated(p.tvi);
let a2b = sample_a2b(&model, p.grid_a2b);
let a2b = sample_a2b(&model, p.grid_a2b)?;
lut_profile(&model, a2b, model.paper, p)
}
/// Samples a forward model into an AToB table with `grid`⁴ nodes (at least 2⁴) and legacy Lab
/// encoding.
fn sample_a2b<F: Forward + ?Sized>(model: &F, grid: usize) -> Clut {
fn sample_a2b<F: Forward + ?Sized>(model: &F, grid: usize) -> Result<Clut, CmsError> {
Clut::sample(vec![grid.max(2); 4], 3, |inp, out| {
let lab = model.lab([inp[0] as f64, inp[1] as f64, inp[2] as f64, inp[3] as f64]);
out.copy_from_slice(&encode_v2(lab));
@@ -458,27 +470,25 @@ fn sample_a2b<F: Forward + ?Sized>(model: &F, grid: usize) -> Clut {
/// A CMYK profile of any forward model on paper with absolute XYZ `paper`: the AToB table
/// samples `model` on `p.grid_a2b`⁴ nodes, and the BToA tables invert that table rather than the
/// model, so Lab → CMYK → Lab round trips through the profile are as close as the separation
/// allows. Grids below 2 nodes and a zero, negative or non-finite `paper` are refused with
/// [`CmsError::Invalid`].
/// allows. Grids below 2 nodes or too large for a [`Clut`], and a zero, negative or
/// non-finite `paper`, are refused with [`CmsError::InvalidArgument`].
pub fn cmyk_profile_from_model<F: Forward + ?Sized>(model: &F, paper: [f64; 3], p: &CmykParams) -> Result<Profile, CmsError> {
if p.grid_a2b < 2 || p.grid_b2a < 2 {
return Err(CmsError::Invalid(format!("CMYK profile grids {}⁴/{}³ need at least 2 nodes", p.grid_a2b, p.grid_b2a)));
}
check_grids(p)?;
if !paper.iter().all(|v| v.is_finite() && *v > 0.0) {
return Err(CmsError::Invalid(format!("paper XYZ {paper:?}")));
return Err(invalid_arg(format!("paper XYZ {paper:?}")));
}
let a2b = sample_a2b(model, p.grid_a2b);
let a2b = sample_a2b(model, p.grid_a2b)?;
let forward = TableForward(a2b.clone());
Ok(lut_profile(&forward, a2b, paper, p))
lut_profile(&forward, a2b, paper, p)
}
fn lut_profile<F: Forward + ?Sized>(model: &F, a2b: Clut, paper: [f64; 3], p: &CmykParams) -> Profile {
fn lut_profile<F: Forward + ?Sized>(model: &F, a2b: Clut, paper: [f64; 3], p: &CmykParams) -> Result<Profile, CmsError> {
let gbd = Gbd::new(model, p);
let colorimetric = b2a_table(model, p, None);
let perceptual = b2a_table(model, p, Some(&gbd));
let colorimetric = b2a_table(model, p, None)?;
let perceptual = b2a_table(model, p, Some(&gbd))?;
let a2b = lut16(a2b);
let (b0, b1) = (lut16(perceptual), lut16(colorimetric));
Profile {
Ok(Profile {
version: (4, 0x30),
class: ProfileClass::Output,
color_space: ColorSpace::Cmyk,
@@ -497,11 +507,11 @@ fn lut_profile<F: Forward + ?Sized>(model: &F, a2b: Clut, paper: [f64; 3], p: &C
bytes: None,
hash: Default::default(),
}
.with_encoded_bytes()
.with_encoded_bytes())
}
/// The default coated profile (what `profiles/photocraft-coated-cmyk.icc` contains).
pub fn coated_cmyk() -> Profile {
pub fn coated_cmyk() -> Result<Profile, CmsError> {
cmyk_profile(&CmykParams::default())
}
+112 -51
View File
@@ -9,7 +9,7 @@ use crate::curve::Curve;
use crate::math;
use crate::pipeline::{Pipeline, Stage};
use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass};
use crate::{CmsError, Intent};
use crate::{CmsError, Intent, check_len, invalid_arg};
/// Sample storage of a byte buffer for [`Transform::convert_bytes_many`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -101,7 +101,7 @@ impl Transform {
pub fn with_options(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result<Transform, CmsError> {
let stages = link_stages(src, dst, opts.intent, opts.bpc)?;
let mut t = Self::from_pipeline(Pipeline::new(src.channels(), stages), opts);
let mut t = Self::from_pipeline(Pipeline::new(src.channels(), stages)?, opts)?;
if opts.intent != Intent::AbsoluteColorimetric {
t.fix_white(src.color_space, dst.color_space);
}
@@ -117,15 +117,15 @@ impl Transform {
stages.push(Stage::Clamp01);
let (i2, b2) = if simulate_paper { (Intent::AbsoluteColorimetric, false) } else { (Intent::RelativeColorimetric, true) };
stages.extend(link_stages(proof, dst, i2, b2)?);
Ok(Self::from_pipeline(Pipeline::new(src.channels(), stages), TransformOptions { intent, bpc, precise_float: true }))
Self::from_pipeline(Pipeline::new(src.channels(), stages)?, TransformOptions { intent, bpc, precise_float: true })
}
/// Builds a transform from an arbitrary pipeline over normalised values.
pub fn from_pipeline(mut pipeline: Pipeline, opts: TransformOptions) -> Transform {
pub fn from_pipeline(mut pipeline: Pipeline, opts: TransformOptions) -> Result<Transform, CmsError> {
pipeline.optimize();
let (inputs, outputs) = (pipeline.inputs, pipeline.outputs);
let (inputs, outputs) = (pipeline.inputs(), pipeline.outputs());
// Split trailing inverse curves into accurate output tables.
let mut head = pipeline.stages.clone();
let mut head = pipeline.stages().to_vec();
let mut post = None;
if let Some(Stage::InvCurves(cs)) = head.last()
&& cs.len() == outputs
@@ -147,8 +147,13 @@ impl Transform {
head.pop();
}
}
let head_pipe = Pipeline::new(inputs, head.clone());
let mid = head_pipe.outputs;
let head_pipe = Pipeline::new(inputs, head.clone())?;
let mid = head_pipe.outputs();
let sampled = |grid: Vec<usize>| match Clut::sample(grid, mid, |i, o| head_pipe.run(i, o)) {
Ok(c) => Core::Grid(c),
// More outputs than a table holds: evaluate exactly.
Err(_) => Core::Exact,
};
let core = match head.as_slice() {
[] => Core::Shaper { curves8: Vec::new(), curves: Vec::new(), matrix: None },
[Stage::Curves(cs)] if cs.len() == inputs => shaper(cs, None),
@@ -158,10 +163,10 @@ impl Transform {
// a grid would amplify interpolation error through steep output curves.
_ if !head.iter().any(|s| matches!(s, Stage::Clut(_))) => Core::Exact,
_ => match inputs {
1 => Core::Grid(Clut::sample(vec![TABLE_1D], mid, |i, o| head_pipe.eval(i, o))),
3 => Core::Grid(Clut::sample(vec![GRID_3D; 3], mid, |i, o| head_pipe.eval(i, o))),
4 => Core::Grid(Clut::sample(vec![GRID_4D; 4], mid, |i, o| head_pipe.eval(i, o))),
2 => Core::Grid(Clut::sample(vec![GRID_3D; 2], mid, |i, o| head_pipe.eval(i, o))),
1 => sampled(vec![TABLE_1D]),
3 => sampled(vec![GRID_3D; 3]),
4 => sampled(vec![GRID_4D; 4]),
2 => sampled(vec![GRID_3D; 2]),
_ => Core::Exact,
},
};
@@ -179,7 +184,7 @@ impl Transform {
.collect(),
_ => Vec::new(),
};
Transform { inputs, outputs, opts, pipeline, core, post, grid8 }
Ok(Transform { inputs, outputs, opts, pipeline, core, post, grid8 })
}
/// White preservation: the device-link node at the source's device white gets exactly the
@@ -226,24 +231,38 @@ impl Transform {
&self.pipeline
}
/// Exact evaluation of one colour (`inputs` → `outputs` normalised values).
#[inline]
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
self.pipeline.eval(input, out);
/// 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> {
self.check_io(input, out)?;
self.pipeline.run(input, out);
Ok(())
}
/// Lookup-table evaluation of one colour (what integer buffers use); slices as in
/// [`Transform::eval`].
pub fn eval_fast(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> {
self.check_io(input, out)?;
self.fast(input, out);
Ok(())
}
fn check_io(&self, input: &[f32], out: &[f32]) -> Result<(), CmsError> {
check_len("transform input", input.len(), self.inputs)?;
check_len("transform output", out.len(), self.outputs)
}
/// Lookup-table evaluation of one colour (what integer buffers use).
#[inline]
pub fn eval_fast(&self, input: &[f32], out: &mut [f32]) {
fn fast(&self, input: &[f32], out: &mut [f32]) {
let mut mid = [0.0f32; 16];
match &self.core {
Core::Exact => return self.pipeline.eval(input, out),
Core::Exact => return self.pipeline.run(input, out),
Core::Grid(c) => {
let mut inp = [0.0f32; 16];
for (d, s) in inp.iter_mut().zip(&input[..self.inputs]) {
*d = s.clamp(0.0, 1.0);
}
c.eval(&inp[..self.inputs], &mut mid[..c.outputs]);
c.interp(&inp[..self.inputs], &mut mid[..c.outputs]);
}
Core::Shaper { curves, matrix, .. } => {
let mut v = [0.0f32; 16];
@@ -269,20 +288,24 @@ impl Transform {
}
/// Converts float pixels. `src`/`dst` hold whole pixels of `src_stride`/`dst_stride`
/// values; colour channels come first. Trailing channels are copied when `copy_extra`.
pub fn convert_f32(&self, src: &[f32], src_stride: usize, dst: &mut [f32], dst_stride: usize, copy_extra: bool) {
self.check(src_stride, dst_stride);
/// values (strides at least `inputs`/`outputs`); colour channels come first. `dst` holds at
/// least as many pixels as `src`; pixels past that are left alone. Trailing channels are
/// copied when `copy_extra`. Buffers that do not fit are refused with
/// [`CmsError::InvalidArgument`] before any pixel is written.
pub fn convert_f32(&self, src: &[f32], src_stride: usize, dst: &mut [f32], dst_stride: usize, copy_extra: bool) -> Result<(), CmsError> {
self.check_buffers(src.len(), src_stride, dst.len(), dst_stride)?;
let extra = extra_channels(self, src_stride, dst_stride, copy_extra);
par_rows(src, src_stride, dst, dst_stride, |s, d| self.run_f32(s, src_stride, d, dst_stride, extra));
Ok(())
}
fn run_f32(&self, s: &[f32], ss: usize, d: &mut [f32], ds: usize, extra: usize) {
let precise = self.opts.precise_float || matches!(self.core, Core::Exact);
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
if precise {
self.pipeline.eval(sp, dp);
self.pipeline.run(sp, dp);
} else {
self.eval_fast(sp, dp);
self.fast(sp, dp);
}
for e in 0..extra {
dp[self.outputs + e] = sp[self.inputs + e];
@@ -290,29 +313,37 @@ impl Transform {
}
}
/// In-place float conversion; `stride ≥ max(inputs, outputs)`.
pub fn apply(&self, buf: &mut [f32], stride: usize) {
assert!(stride >= self.inputs.max(self.outputs), "stride too small");
/// In-place float conversion of whole pixels of `stride ≥ max(inputs, outputs)` values.
pub fn apply(&self, buf: &mut [f32], stride: usize) -> Result<(), CmsError> {
let need = self.inputs.max(self.outputs);
if stride < need {
return Err(invalid_arg(format!("pixel stride {stride} is below the channel count {need}")));
}
if !buf.len().is_multiple_of(stride) {
return Err(invalid_arg(format!("buffer of {} values is not whole pixels of {stride}", buf.len())));
}
let precise = self.opts.precise_float || matches!(self.core, Core::Exact);
let body = |chunk: &mut [f32]| {
let mut out = [0.0f32; 16];
for p in chunk.chunks_exact_mut(stride) {
if precise {
self.pipeline.eval(p, &mut out);
self.pipeline.run(p, &mut out);
} else {
self.eval_fast(p, &mut out);
self.fast(p, &mut out);
}
p[..self.outputs].copy_from_slice(&out[..self.outputs]);
}
};
par_inplace(buf, stride, body);
Ok(())
}
/// Converts 8-bit pixels (see [`Transform::convert_f32`] for the layout).
pub fn convert_u8(&self, src: &[u8], src_stride: usize, dst: &mut [u8], dst_stride: usize, copy_extra: bool) {
self.check(src_stride, dst_stride);
pub fn convert_u8(&self, src: &[u8], src_stride: usize, dst: &mut [u8], dst_stride: usize, copy_extra: bool) -> Result<(), CmsError> {
self.check_buffers(src.len(), src_stride, dst.len(), dst_stride)?;
let extra = extra_channels(self, src_stride, dst_stride, copy_extra);
par_rows(src, src_stride, dst, dst_stride, |s, d| self.run_u8(s, src_stride, d, dst_stride, extra));
Ok(())
}
fn run_u8(&self, s: &[u8], ss: usize, d: &mut [u8], ds: usize, extra: usize) {
@@ -384,7 +415,7 @@ impl Transform {
for k in 0..self.inputs {
inp[k] = sp[k] as f32 / 255.0;
}
self.eval_fast(&inp, &mut out);
self.fast(&inp, &mut out);
for k in 0..self.outputs {
dp[k] = q(out[k]);
}
@@ -397,10 +428,11 @@ impl Transform {
}
/// Converts 16-bit pixels (see [`Transform::convert_f32`] for the layout).
pub fn convert_u16(&self, src: &[u16], src_stride: usize, dst: &mut [u16], dst_stride: usize, copy_extra: bool) {
self.check(src_stride, dst_stride);
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)?;
let extra = extra_channels(self, src_stride, dst_stride, copy_extra);
par_rows(src, src_stride, dst, dst_stride, |s, d| self.run_u16(s, src_stride, d, dst_stride, extra));
Ok(())
}
fn run_u16(&self, s: &[u16], ss: usize, d: &mut [u16], ds: usize, extra: usize) {
@@ -410,7 +442,7 @@ impl Transform {
for k in 0..self.inputs {
inp[k] = sp[k] as f32 / 65535.0;
}
self.eval_fast(&inp, &mut out);
self.fast(&inp, &mut out);
for k in 0..self.outputs {
dp[k] = (out[k].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
}
@@ -422,9 +454,27 @@ impl Transform {
/// Converts many independent buffers of native-endian samples in one parallel pass (e.g.
/// all tiles of a surface): far cheaper than one parallel call per small buffer. Each job
/// is `(src bytes, dst bytes)` holding whole pixels of `src_stride`/`dst_stride` samples.
pub fn convert_bytes_many(&self, sample: SampleKind, jobs: Vec<(&[u8], &mut [u8])>, src_stride: usize, dst_stride: usize, copy_extra: bool) {
self.check(src_stride, dst_stride);
/// is `(src bytes, dst bytes)` holding whole pixels of `src_stride`/`dst_stride` samples,
/// as in [`Transform::convert_f32`]. Every job is checked before any is converted.
pub fn convert_bytes_many(
&self,
sample: SampleKind,
jobs: Vec<(&[u8], &mut [u8])>,
src_stride: usize,
dst_stride: usize,
copy_extra: bool,
) -> Result<(), CmsError> {
let size = match sample {
SampleKind::U8 => 1,
SampleKind::U16 => 2,
SampleKind::F32 => 4,
};
for (s, d) in &jobs {
if !s.len().is_multiple_of(size) || !d.len().is_multiple_of(size) {
return Err(invalid_arg(format!("byte buffers of {}/{} bytes are not whole {size}-byte samples", s.len(), d.len())));
}
self.check_buffers(s.len() / size, src_stride, d.len() / size, dst_stride)?;
}
let extra = extra_channels(self, src_stride, dst_stride, copy_extra);
let run = |(s, d): (&[u8], &mut [u8])| match sample {
SampleKind::U8 => self.run_u8(s, src_stride, d, dst_stride, extra),
@@ -452,10 +502,21 @@ impl Transform {
}
#[cfg(target_arch = "wasm32")]
jobs.into_iter().for_each(run);
Ok(())
}
fn check(&self, ss: usize, ds: usize) {
assert!(ss >= self.inputs && ds >= self.outputs, "pixel stride smaller than the channel count");
/// Checks a conversion's buffers once per call, so the per-pixel loops need no checks.
fn check_buffers(&self, src_len: usize, ss: usize, dst_len: usize, ds: usize) -> Result<(), CmsError> {
if ss < self.inputs || ds < self.outputs {
return Err(invalid_arg(format!("pixel strides {ss}/{ds} are below the channel counts {}/{}", self.inputs, self.outputs)));
}
if !src_len.is_multiple_of(ss) || !dst_len.is_multiple_of(ds) {
return Err(invalid_arg(format!("buffers of {src_len}/{dst_len} samples are not whole pixels of {ss}/{ds} samples")));
}
if dst_len / ds < src_len / ss {
return Err(invalid_arg(format!("destination holds {} pixels, source {}", dst_len / ds, src_len / ss)));
}
Ok(())
}
}
@@ -644,21 +705,21 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result<f64, Cms
if lut_based && p.version.0 >= 4 && matches!(intent, Intent::Perceptual | Intent::Saturation) {
return Ok(PERCEPTUAL_BLACK[1]);
}
let pcs_y = |st: Vec<Stage>, pcs: Pcs, dev: &[f32]| -> f64 {
let pipe = Pipeline::new(p.channels(), st);
let pcs_y = |st: Vec<Stage>, pcs: Pcs, dev: &[f32]| -> Result<f64, CmsError> {
let pipe = Pipeline::new(p.channels(), st)?;
let mut o = [0.0f32; 16];
pipe.eval(dev, &mut o);
pipe.eval(dev, &mut o)?;
let y = match pcs {
Pcs::Xyz => o[1] as f64,
Pcs::Lab => math::lab_to_xyz([o[0] as f64, 0.0, 0.0], math::D50)[1],
};
y.clamp(0.0, 0.5)
Ok(y.clamp(0.0, 0.5))
};
let (to_pcs, pcs) = p.device_to_pcs(intent)?;
let additive = matches!(p.color_space, ColorSpace::Rgb | ColorSpace::Gray);
if additive || !lut_based {
let zero = vec![0.0f32; p.channels()];
return Ok(pcs_y(to_pcs, pcs, &zero));
return pcs_y(to_pcs, pcs, &zero);
}
// Subtractive LUT profile: darkest reachable colour.
let colorimetric = if intent == Intent::Saturation { Intent::Saturation } else { Intent::RelativeColorimetric };
@@ -669,14 +730,14 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result<f64, Cms
st.push(Stage::LabToXyz);
}
st.extend(from_pcs);
let pipe = Pipeline::new(3, st);
let pipe = Pipeline::new(3, st)?;
let mut dev = [0.0f32; 16];
pipe.eval(&[0.0, 0.0, 0.0], &mut dev);
Ok(pcs_y(to_pcs, pcs, &dev[..p.channels()]))
pipe.eval(&[0.0, 0.0, 0.0], &mut dev)?;
pcs_y(to_pcs, pcs, dev.get(..p.channels()).unwrap_or(&dev))
}
Err(_) => {
let ones = vec![1.0f32; p.channels()];
Ok(pcs_y(to_pcs, pcs, &ones))
pcs_y(to_pcs, pcs, &ones)
}
}
}
+2 -2
View File
@@ -19,11 +19,11 @@ fn bench_srgb8_to_cmyk8() {
}
let mut dst = vec![0u8; n * 5];
let t1 = std::time::Instant::now();
t.convert_u8(&src, 4, &mut dst, 5, true);
t.convert_u8(&src, 4, &mut dst, 5, true).unwrap();
eprintln!("convert 6016² RGBA8 → CMYKA8 (one buffer): {:?}", t1.elapsed());
let t2 = std::time::Instant::now();
for (s, d) in src.chunks(65536 * 4).zip(dst.chunks_mut(65536 * 5)) {
t.convert_u8(s, 4, d, 5, true);
t.convert_u8(s, 4, d, 5, true).unwrap();
}
eprintln!("convert per 256² tile: {:?}", t2.elapsed());
}
+118 -49
View File
@@ -26,7 +26,7 @@ fn enc(l: [f64; 3]) -> [f32; 3] {
fn to_lab(p: &Profile, dev: &[f32], intent: Intent) -> [f64; 3] {
let t = Transform::new(p, lab(), intent, false).unwrap();
let mut o = [0.0f32; 3];
t.eval(dev, &mut o);
t.eval(dev, &mut o).unwrap();
dec(&o)
}
@@ -83,16 +83,16 @@ fn rgb_lab_rgb_roundtrip_8bit_and_float() {
}
let f: Vec<f32> = src8.iter().map(|v| *v as f32 / 255.0).collect();
let mut labf = vec![0.0f32; f.len()];
to.convert_f32(&f, 3, &mut labf, 3, false);
to.convert_f32(&f, 3, &mut labf, 3, false).unwrap();
let mut rgbf = vec![0.0f32; f.len()];
back.convert_f32(&labf, 3, &mut rgbf, 3, false);
back.convert_f32(&labf, 3, &mut rgbf, 3, false).unwrap();
for (a, b) in src8.iter().zip(&rgbf) {
assert!((*a as f32 - b * 255.0).abs() <= 1.0, "{a} vs {}", b * 255.0);
}
// 8-bit RGB stored as 16-bit Lab then back to 8-bit through the lookup tables.
let lab16: Vec<u16> = labf.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect();
let mut rgb16 = vec![0u16; lab16.len()];
back.convert_u16(&lab16, 3, &mut rgb16, 3, false);
back.convert_u16(&lab16, 3, &mut rgb16, 3, false).unwrap();
for (a, b) in src8.iter().zip(&rgb16) {
assert!((*a as f32 - *b as f32 / 257.0).abs() <= 1.0, "{a} vs {}", *b as f32 / 257.0);
}
@@ -102,7 +102,7 @@ fn rgb_lab_rgb_roundtrip_8bit_and_float() {
}
// Fast 8-bit path: RGB8 → Lab8-grid → compare with exact within 1 level of Lab encoding.
let mut lab8 = vec![0u8; src8.len()];
to.convert_u8(&src8, 3, &mut lab8, 3, false);
to.convert_u8(&src8, 3, &mut lab8, 3, false).unwrap();
for (q, e) in lab8.iter().zip(&labf) {
assert!((*q as f32 - e * 255.0).abs() <= 1.0);
}
@@ -115,10 +115,10 @@ fn rgb_to_rgb_matrix_shaper_paths_agree() {
let t = Transform::new(srgb(), dst.profile(), Intent::RelativeColorimetric, false).unwrap();
let src: Vec<u8> = (0..=255u8).flat_map(|v| [v, v / 2, 255 - v]).collect();
let mut out = vec![0u8; src.len()];
t.convert_u8(&src, 3, &mut out, 3, false);
t.convert_u8(&src, 3, &mut out, 3, false).unwrap();
for (px, o) in src.chunks(3).zip(out.chunks(3)) {
let mut e = [0.0f32; 3];
t.eval(&[px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0], &mut e);
t.eval(&[px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0], &mut e).unwrap();
for k in 0..3 {
assert!((o[k] as f32 - e[k].clamp(0.0, 1.0) * 255.0).abs() <= 0.6, "{dst:?} {px:?}: {o:?} vs {e:?}");
}
@@ -131,7 +131,7 @@ fn identity_transform_is_identity() {
let t = Transform::new(srgb(), srgb(), Intent::RelativeColorimetric, true).unwrap();
let src: Vec<u8> = (0..=255u8).flat_map(|v| [v, 255 - v, v / 3, 77]).collect();
let mut out = vec![0u8; src.len()];
t.convert_u8(&src, 4, &mut out, 4, true);
t.convert_u8(&src, 4, &mut out, 4, true).unwrap();
assert_eq!(src, out);
}
@@ -162,13 +162,13 @@ fn cmyk_profile_is_monotonic_and_in_range() {
for v in (0..=255).rev().step_by(5) {
let g = v as f32 / 255.0;
let mut o = [0.0f32; 4];
t.eval(&[g, g, g], &mut o);
t.eval(&[g, g, g], &mut o).unwrap();
assert!(o.iter().all(|c| (-1e-6..=1.0 + 1e-6).contains(c)), "{o:?}");
assert!(o.iter().sum::<f32>() <= 3.0 + 0.02, "TAC {o:?}");
assert!(o[3] >= prev_k - 0.01, "{intent:?} K not monotonic at {v}: {o:?}");
prev_k = o[3];
let mut l = [0.0f32; 3];
back.eval(&o, &mut l);
back.eval(&o, &mut l).unwrap();
let lab = dec(&l);
assert!(lab[0] <= prev_l + 0.3, "{intent:?} L* not monotonic at {v}: {lab:?}");
assert!(lab[1].abs() < 3.0 && lab[2].abs() < 3.0, "{intent:?} gray {v} not neutral: {lab:?}");
@@ -179,7 +179,7 @@ fn cmyk_profile_is_monotonic_and_in_range() {
let t = Transform::new(srgb(), p, Intent::RelativeColorimetric, true).unwrap();
let src: Vec<u8> = (0..4096u32).flat_map(|i| [(i * 37 % 256) as u8, (i * 91 % 256) as u8, (i * 13 % 256) as u8]).collect();
let mut out = vec![0u8; 4096 * 4];
t.convert_u8(&src, 3, &mut out, 4, false);
t.convert_u8(&src, 3, &mut out, 4, false).unwrap();
for px in out.chunks(4) {
assert!(px.iter().map(|v| *v as u32).sum::<u32>() <= 3 * 255 + 8, "{px:?}");
}
@@ -191,9 +191,9 @@ fn in_gamut_colours_survive_cmyk_roundtrip() {
let back = Transform::new(cmyk(), lab(), Intent::RelativeColorimetric, false).unwrap();
for target in [[50.0, 0.0, 0.0], [70.0, 10.0, 10.0], [60.0, -20.0, -20.0], [80.0, 0.0, 40.0], [40.0, 30.0, -10.0]] {
let mut c = [0.0f32; 4];
to.eval(&enc(target), &mut c);
to.eval(&enc(target), &mut c).unwrap();
let mut l = [0.0f32; 3];
back.eval(&c, &mut l);
back.eval(&c, &mut l).unwrap();
assert!(delta_e76(dec(&l), target) < 2.0, "{target:?} -> {c:?} -> {:?}", dec(&l));
}
}
@@ -205,9 +205,9 @@ fn intents_differ_as_expected() {
let sat = Transform::new(srgb(), cmyk(), Intent::Saturation, false).unwrap();
let (mut a, mut b, mut s) = ([0.0f32; 4], [0.0f32; 4], [0.0f32; 4]);
// Out-of-gamut sRGB blue: perceptual compresses, relative clips: different separations.
per.eval(&[0.0, 0.2, 1.0], &mut a);
rel.eval(&[0.0, 0.2, 1.0], &mut b);
sat.eval(&[0.0, 0.2, 1.0], &mut s);
per.eval(&[0.0, 0.2, 1.0], &mut a).unwrap();
rel.eval(&[0.0, 0.2, 1.0], &mut b).unwrap();
sat.eval(&[0.0, 0.2, 1.0], &mut s).unwrap();
let d: f32 = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).sum();
assert!(d > 0.03, "perceptual {a:?} vs relative {b:?}");
assert_eq!(a, s, "saturation shares the perceptual table");
@@ -216,8 +216,8 @@ fn intents_differ_as_expected() {
let rel = Transform::new(cmyk(), srgb(), Intent::RelativeColorimetric, false).unwrap();
let abs = Transform::new(cmyk(), srgb(), Intent::AbsoluteColorimetric, false).unwrap();
let (mut w1, mut w2) = ([0.0f32; 3], [0.0f32; 3]);
rel.eval(&[0.0; 4], &mut w1);
abs.eval(&[0.0; 4], &mut w2);
rel.eval(&[0.0; 4], &mut w1).unwrap();
abs.eval(&[0.0; 4], &mut w2).unwrap();
assert!(w1.iter().all(|v| *v > 0.995), "{w1:?}");
assert!(w2[0] < 0.97 && w2[2] > w2[0], "{w2:?}");
let l = to_lab(cmyk(), &[0.0; 4], Intent::AbsoluteColorimetric);
@@ -259,7 +259,7 @@ fn absolute_colorimetric_refuses_a_degenerate_media_white() {
Err(e) => panic!("{name}: wrong error {e}"),
Ok(t) => {
let mut out = [0.0f32; 3];
t.eval(&[0.2, 0.4, 0.6], &mut out);
t.eval(&[0.2, 0.4, 0.6], &mut out).unwrap();
panic!("{name}: built a transform that gives {out:?}");
}
}
@@ -270,17 +270,17 @@ fn absolute_colorimetric_refuses_a_degenerate_media_white() {
// Without paper simulation the proof leg is relative colorimetric and stays finite.
let t = Transform::proof(srgb(), bad, srgb(), Intent::RelativeColorimetric, true, false).unwrap();
let mut out = [0.0f32; 3];
t.eval(&[0.2, 0.4, 0.6], &mut out);
t.eval(&[0.2, 0.4, 0.6], &mut out).unwrap();
assert!(out.iter().all(|v| v.is_finite()), "{name}: {out:?}");
let t = Transform::new(srgb(), bad, Intent::RelativeColorimetric, false).unwrap();
t.eval(&[0.2, 0.4, 0.6], &mut out);
t.eval(&[0.2, 0.4, 0.6], &mut out).unwrap();
assert!(out.iter().all(|v| v.is_finite()), "{name}: {out:?}");
}
// A sane patched white still builds and scales.
let d50 = srgb_with_wtpt([0xF6D6, 0x10000, 0xD32D]);
let t = Transform::new(srgb(), &d50, Intent::AbsoluteColorimetric, false).unwrap();
let mut out = [0.0f32; 3];
t.eval(&[0.2, 0.4, 0.6], &mut out);
t.eval(&[0.2, 0.4, 0.6], &mut out).unwrap();
assert!(out.iter().all(|v| v.is_finite()), "{out:?}");
}
@@ -297,9 +297,9 @@ fn black_point_compensation() {
let back = Transform::new(cmyk(), lab(), Intent::RelativeColorimetric, false).unwrap();
let l_of = |t: &Transform, v: f32| {
let mut c = [0.0f32; 4];
t.eval(&[v, v, v], &mut c);
t.eval(&[v, v, v], &mut c).unwrap();
let mut l = [0.0f32; 3];
back.eval(&c, &mut l);
back.eval(&c, &mut l).unwrap();
dec(&l)[0]
};
let (w0, w1) = (l_of(&with, 0.0), l_of(&with, 0.08));
@@ -309,14 +309,14 @@ fn black_point_compensation() {
// CMYK → sRGB: with BPC the darkest CMYK maps to (near) sRGB black.
let dark = {
let mut c = [0.0f32; 4];
with.eval(&[0.0, 0.0, 0.0], &mut c);
with.eval(&[0.0, 0.0, 0.0], &mut c).unwrap();
c
};
let rgb_with = Transform::new(cmyk(), srgb(), Intent::RelativeColorimetric, true).unwrap();
let rgb_without = Transform::new(cmyk(), srgb(), Intent::RelativeColorimetric, false).unwrap();
let (mut a, mut b) = ([0.0f32; 3], [0.0f32; 3]);
rgb_with.eval(&dark, &mut a);
rgb_without.eval(&dark, &mut b);
rgb_with.eval(&dark, &mut a).unwrap();
rgb_without.eval(&dark, &mut b).unwrap();
assert!(a[1] < 0.03 && b[1] > a[1] + 0.03, "with {a:?} without {b:?}");
}
@@ -324,7 +324,7 @@ fn black_point_compensation() {
fn tetrahedral_beats_trilinear() {
// Sample sRGB → Lab on a coarse grid and compare interpolation against the exact pipeline.
let exact = Transform::new(srgb(), lab(), Intent::RelativeColorimetric, false).unwrap();
let clut = Clut::sample(vec![9, 9, 9], 3, |i, o| exact.eval(i, o));
let clut = Clut::sample(vec![9, 9, 9], 3, |i, o| exact.eval(i, o).unwrap()).unwrap();
let (mut tet_max, mut tri_max, mut tet_sum, mut tri_sum, mut tet_neutral, mut tri_neutral) = (0.0f64, 0.0f64, 0.0, 0.0, 0.0f64, 0.0f64);
let mut n = 0.0;
let mut seed = 12345u32;
@@ -340,9 +340,9 @@ fn tetrahedral_beats_trilinear() {
[rnd(), rnd(), rnd()]
};
let (mut e, mut a, mut b) = ([0.0f32; 3], [0.0f32; 3], [0.0f32; 3]);
exact.eval(&p, &mut e);
clut.eval(&p, &mut a);
clut.eval_trilinear(&p, &mut b);
exact.eval(&p, &mut e).unwrap();
clut.eval(&p, &mut a).unwrap();
clut.eval_trilinear(&p, &mut b).unwrap();
let (da, db) = (delta_e76(dec(&a), dec(&e)), delta_e76(dec(&b), dec(&e)));
if i < 200 {
tet_neutral = tet_neutral.max(dec(&a)[1].abs().max(dec(&a)[2].abs()));
@@ -366,24 +366,24 @@ fn proof_and_display_lut() {
let p = Transform::proof(srgb(), cmyk(), srgb(), Intent::RelativeColorimetric, true, false).unwrap();
let mut o = [0.0f32; 3];
// Neutral gray stays (nearly) neutral through the proof.
p.eval(&[0.5, 0.5, 0.5], &mut o);
p.eval(&[0.5, 0.5, 0.5], &mut o).unwrap();
assert!((o[0] - o[1]).abs() < 0.02 && (o[1] - o[2]).abs() < 0.03, "{o:?}");
// Saturated blue is desaturated by the CMYK gamut.
p.eval(&[0.0, 0.0, 1.0], &mut o);
p.eval(&[0.0, 0.0, 1.0], &mut o).unwrap();
assert!(o[0] > 0.1 || o[1] > 0.1, "{o:?}");
// Paper simulation darkens white.
let sim = Transform::proof(srgb(), cmyk(), srgb(), Intent::RelativeColorimetric, true, true).unwrap();
sim.eval(&[1.0, 1.0, 1.0], &mut o);
sim.eval(&[1.0, 1.0, 1.0], &mut o).unwrap();
assert!(o[0] < 0.97, "{o:?}");
let t = Transform::new(srgb(), Builtin::DisplayP3.profile(), Intent::RelativeColorimetric, false).unwrap();
let lut = Lut3d::from_transform(&t, 33);
let lut = Lut3d::from_transform(&t, 33).unwrap();
assert_eq!(lut.to_rgba16f_bytes().len(), 33 * 33 * 33 * 8);
let mut worst = 0.0f32;
for i in 0..500u32 {
let c = [(i * 7 % 101) as f32 / 100.0, (i * 13 % 101) as f32 / 100.0, (i * 29 % 101) as f32 / 100.0];
let mut e = [0.0f32; 3];
t.eval(&c, &mut e);
t.eval(&c, &mut e).unwrap();
let s = lut.sample(c);
for k in 0..3 {
worst = worst.max((s[k] - e[k]).abs());
@@ -397,16 +397,16 @@ fn gray_profiles() {
let t = Transform::new(Builtin::SGray.profile(), srgb(), Intent::RelativeColorimetric, false).unwrap();
for v in [0.0f32, 0.2, 0.5, 0.9, 1.0] {
let mut o = [0.0f32; 3];
t.eval(&[v], &mut o);
t.eval(&[v], &mut o).unwrap();
for c in o {
assert!((c - v).abs() < 1e-3, "sGray {v} -> {o:?}");
}
}
let t = Transform::new(srgb(), Builtin::GrayGamma22.profile(), Intent::RelativeColorimetric, false).unwrap();
let mut o = [0.0f32; 1];
t.eval(&[1.0, 1.0, 1.0], &mut o);
t.eval(&[1.0, 1.0, 1.0], &mut o).unwrap();
assert!((o[0] - 1.0).abs() < 1e-3);
t.eval(&[0.0, 0.0, 0.0], &mut o);
t.eval(&[0.0, 0.0, 0.0], &mut o).unwrap();
assert!(o[0].abs() < 1e-3);
}
@@ -423,8 +423,8 @@ fn hdr_floats_survive_matrix_transforms() {
let src = [4.0f32, 2.0, 0.5];
let mut mid = [0.0f32; 3];
let mut out = [0.0f32; 3];
t.eval(&src, &mut mid);
back.eval(&mid, &mut out);
t.eval(&src, &mut mid).unwrap();
back.eval(&mid, &mut out).unwrap();
for k in 0..3 {
assert!((out[k] - src[k]).abs() < 1e-3 * src[k].max(1.0), "{src:?} -> {mid:?} -> {out:?}");
}
@@ -482,9 +482,12 @@ fn a_light_black_cmyk_lut_has_the_known_relative_ramp() {
outputs: 3,
stages: vec![
Stage::Curves(vec![Curve::Identity; 4]),
Stage::Clut(Clut::sample(vec![2; 4], 3, |ink, out| {
out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0]));
})),
Stage::Clut(
Clut::sample(vec![2; 4], 3, |ink, out| {
out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0]));
})
.unwrap(),
),
Stage::Curves(vec![Curve::Identity; 3]),
],
});
@@ -557,7 +560,7 @@ fn system_profiles() {
let t = Transform::new(&p, lab(), intent, true).unwrap_or_else(|e| panic!("{} {intent:?}: {e}", path.display()));
let n = p.channels();
let mut o = [0.0f32; 3];
t.eval(&vec![0.5; n], &mut o);
t.eval(&vec![0.5; n], &mut o).unwrap();
assert!(o.iter().all(|v| v.is_finite()), "{}", path.display());
}
// Additive white is (near) L* 100 for display profiles in relative colorimetric.
@@ -576,9 +579,9 @@ fn system_profiles() {
let to = Transform::new(lab(), &p, Intent::RelativeColorimetric, false).unwrap();
let back = Transform::new(&p, lab(), Intent::RelativeColorimetric, false).unwrap();
let mut c = [0.0f32; 4];
to.eval(&enc([60.0, 10.0, -10.0]), &mut c);
to.eval(&enc([60.0, 10.0, -10.0]), &mut c).unwrap();
let mut l = [0.0f32; 3];
back.eval(&c, &mut l);
back.eval(&c, &mut l).unwrap();
assert!(delta_e76(dec(&l), [60.0, 10.0, -10.0]) < 3.0, "{}: {:?}", path.display(), dec(&l));
}
}
@@ -601,7 +604,7 @@ fn oracle_moxcms_rgb() {
mt.transform(&src, &mut theirs).unwrap();
let ours_t = Transform::new(srgb(), dst.profile(), Intent::RelativeColorimetric, false).unwrap();
let mut ours = vec![0u8; src.len()];
ours_t.convert_u8(&src, 3, &mut ours, 3, false);
ours_t.convert_u8(&src, 3, &mut ours, 3, false).unwrap();
let worst = ours.iter().zip(&theirs).map(|(a, b)| (*a as i32 - *b as i32).abs()).max().unwrap();
assert!(worst <= 2, "{dst:?}: max diff {worst}");
}
@@ -622,7 +625,7 @@ fn oracle_moxcms_system_cmyk() {
mt.transform(&src, &mut theirs).unwrap();
let t = Transform::new(&ours_p, srgb(), Intent::RelativeColorimetric, false).unwrap();
let mut ours = vec![0u8; 2048 * 3];
t.convert_u8(&src, 4, &mut ours, 3, false);
t.convert_u8(&src, 4, &mut ours, 3, false).unwrap();
let diffs: Vec<i32> = ours.iter().zip(&theirs).map(|(a, b)| (*a as i32 - *b as i32).abs()).collect();
let worst = *diffs.iter().max().unwrap();
let mean = diffs.iter().sum::<i32>() as f64 / diffs.len() as f64;
@@ -824,3 +827,69 @@ fn device_links_parse_and_are_reported_unsupported() {
// The output space still bounds the tag: a CMYK→CMYK link with a 4→3 table is malformed.
assert!(matches!(Profile::parse(&device_link(b"CMYK", b"CMYK", 4, 3)), Err(craft_cms::CmsError::Invalid(_))));
}
fn is_arg_err<T: std::fmt::Debug>(r: Result<T, craft_cms::CmsError>) -> bool {
matches!(r, Err(craft_cms::CmsError::InvalidArgument(_)))
}
/// Strides, buffer lengths and slices that do not fit are refused before anything is written.
#[test]
fn transform_rejects_bad_buffers() {
let t = Transform::new(srgb(), cmyk(), Intent::RelativeColorimetric, true).unwrap();
let src = [255u8; 12];
let mut dst = [7u8; 16];
assert!(is_arg_err(t.convert_u8(&src, 2, &mut dst, 4, false)), "source stride below 3 inputs");
assert!(is_arg_err(t.convert_u8(&src, 3, &mut dst, 3, false)), "destination stride below 4 outputs");
assert!(is_arg_err(t.convert_u8(&src[..11], 3, &mut dst, 4, false)), "partial source pixel");
assert!(is_arg_err(t.convert_u8(&src, 3, &mut dst[..15], 4, false)), "partial destination pixel");
assert!(is_arg_err(t.convert_u8(&src, 3, &mut dst[..12], 4, false)), "destination too short");
assert_eq!(dst, [7u8; 16], "refused calls write nothing");
t.convert_u8(&src, 3, &mut dst, 4, false).unwrap();
assert!(is_arg_err(t.convert_u16(&[0u16; 6], 3, &mut [0u16; 4], 4, false)));
assert!(is_arg_err(t.convert_f32(&[0.0; 7], 3, &mut [0.0; 12], 4, false)));
assert!(is_arg_err(t.apply(&mut [0.0; 12], 3)), "in-place stride below 4 outputs");
assert!(is_arg_err(t.apply(&mut [0.0; 10], 4)), "partial in-place pixel");
let (s, mut d) = ([0u8; 6], [0u8; 7]);
assert!(is_arg_err(t.convert_bytes_many(craft_cms::SampleKind::U16, vec![(&s[..], &mut d[..])], 3, 4, false)));
let mut out = [0.0f32; 4];
assert!(is_arg_err(t.eval(&[1.0, 1.0], &mut out)));
assert!(is_arg_err(t.eval(&[1.0; 3], &mut out[..3])));
assert!(is_arg_err(t.eval_fast(&[1.0, 1.0], &mut out)));
let g = craft_cms::GamutCheck::new(srgb(), cmyk(), 4.0).unwrap();
assert!(is_arg_err(g.delta_e(&[0.5, 0.5])));
assert!(is_arg_err(Lut3d::from_transform(&t, 33)), "3→4 transform");
let d = Transform::new(srgb(), Builtin::DisplayP3.profile(), Intent::RelativeColorimetric, true).unwrap();
assert!(is_arg_err(Lut3d::from_transform(&d, 1)));
assert!(is_arg_err(Lut3d::from_transform(&d, 100_000)));
}
/// Table and pipeline dimensions that do not fit are refused.
#[test]
fn tables_and_pipelines_reject_bad_dimensions() {
use craft_cms::pipeline::{Pipeline, Stage};
assert!(is_arg_err(Clut::new(vec![2, 2], 3, vec![0.0; 11])), "data length");
assert!(is_arg_err(Clut::new(vec![2, 0], 3, Vec::new())), "zero grid points");
assert!(is_arg_err(Clut::new(vec![2; 16], 1, vec![0.0; 1 << 16])), "16 inputs");
assert!(is_arg_err(Clut::new(vec![2], 0, Vec::new())), "no outputs");
assert!(is_arg_err(Clut::sample(vec![usize::MAX; 3], 3, |_, _| {})), "overflowing grid");
assert!(is_arg_err(Clut::sample(vec![1000; 3], 3, |_, _| {})), "grid over the size limit");
let c = Clut::sample(vec![2; 4], 3, |_, _| {}).unwrap();
assert!(is_arg_err(c.eval(&[0.0; 3], &mut [0.0; 3])));
assert!(is_arg_err(c.eval(&[0.0; 4], &mut [0.0; 2])));
assert!(is_arg_err(c.eval_trilinear(&[0.0; 4], &mut [0.0; 3])), "trilinear needs 3 inputs");
assert!(is_arg_err(Pipeline::new(0, Vec::new())));
assert!(is_arg_err(Pipeline::new(17, Vec::new())));
let bad = Stage::Matrix { rows: 3, cols: 3, m: vec![1.0; 8], offset: vec![0.0; 3] };
assert!(is_arg_err(Pipeline::new(3, vec![bad.clone()])), "matrix data shorter than its shape");
let wide = Stage::Matrix { rows: 40, cols: 1, m: vec![1.0; 40], offset: vec![0.0; 40] };
assert!(is_arg_err(Pipeline::new(1, vec![wide])), "too many rows");
let mut p = Pipeline::new(3, vec![Stage::Clamp01]).unwrap();
assert!(is_arg_err(p.extend([bad])));
assert_eq!(p.stages().len(), 1, "a refused extend leaves the pipeline unchanged");
assert!(is_arg_err(p.eval(&[0.0; 2], &mut [0.0; 3])));
assert!(craft_cms::lutfile::LutFile::from_fn("x", 1, |c| c).is_err());
assert!(craft_cms::lutfile::LutFile::identity(1 << 20).is_err());
let params = |a2b, b2a| craft_cms::synth::CmykParams { grid_a2b: a2b, grid_b2a: b2a, ..Default::default() };
assert!(is_arg_err(craft_cms::synth::cmyk_profile(&params(1, 21))));
assert!(is_arg_err(craft_cms::synth::cmyk_profile(&params(11, 100_000))));
}
+4 -4
View File
@@ -84,7 +84,7 @@ fn profile_lab(profile: &Profile) -> impl Fn([f64; 4]) -> [f64; 3] {
let t = Transform::new(profile, Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap();
move |c| {
let mut o = [0.0f32; 3];
t.eval(&c.map(|v| v as f32), &mut o);
t.eval(&c.map(|v| v as f32), &mut o).unwrap();
[o[0] as f64 * 100.0, o[1] as f64 * 255.0 - 128.0, o[2] as f64 * 255.0 - 128.0]
}
}
@@ -444,7 +444,7 @@ fn in_gamut_lab_round_trips() {
let target = to_lab(p.cmyk);
let enc = [(target[0] / 100.0) as f32, ((target[1] + 128.0) / 255.0) as f32, ((target[2] + 128.0) / 255.0) as f32];
let mut c = [0.0f32; 4];
to_cmyk.eval(&enc, &mut c);
to_cmyk.eval(&enc, &mut c).unwrap();
assert!(c.iter().map(|v| *v as f64).sum::<f64>() <= 3.3 + 0.02, "{target:?} -> {c:?}");
delta_e2000(to_lab(c.map(f64::from)), target)
})
@@ -473,7 +473,7 @@ fn to_srgb(profile: &Profile) -> impl Fn([f64; 4]) -> [u8; 3] {
let t = Transform::new(profile, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap();
move |c| {
let mut o = [0.0f32; 3];
t.eval(&c.map(|v| v as f32), &mut o);
t.eval(&c.map(|v| v as f32), &mut o).unwrap();
o.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
}
}
@@ -481,7 +481,7 @@ fn to_srgb(profile: &Profile) -> impl Fn([f64; 4]) -> [u8; 3] {
fn srgb_lab(rgb: [u8; 3]) -> [f64; 3] {
let t = Transform::new(Builtin::Srgb.profile(), Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap();
let mut o = [0.0f32; 3];
t.eval(&rgb.map(|v| v as f32 / 255.0), &mut o);
t.eval(&rgb.map(|v| v as f32 / 255.0), &mut o).unwrap();
[o[0] as f64 * 100.0, o[1] as f64 * 255.0 - 128.0, o[2] as f64 * 255.0 - 128.0]
}
+2 -2
View File
@@ -15,7 +15,7 @@ fn regen_or_check_coated_cmyk() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/profiles/photocraft-coated-cmyk.icc");
if std::env::var_os("CRAFT_CMS_REGEN_PROFILES").is_some() {
let t = std::time::Instant::now();
let p = craft_cms::synth::coated_cmyk();
let p = craft_cms::synth::coated_cmyk().unwrap();
let bytes = p.to_bytes();
std::fs::write(path, &*bytes).unwrap();
eprintln!("wrote {} bytes in {:?}", bytes.len(), t.elapsed());
@@ -32,7 +32,7 @@ fn regen_or_check_coated_cmyk() {
for y in [0.0, 0.55, 1.0] {
for k in [0.0, 0.5, 1.0] {
let mut o = [0.0f32; 3];
t.eval(&[c as f32, m as f32, y as f32, k as f32], &mut o);
t.eval(&[c as f32, m as f32, y as f32, k as f32], &mut o).unwrap();
let got = [o[0] as f64 * 100.0, o[1] as f64 * 255.0 - 128.0, o[2] as f64 * 255.0 - 128.0];
let want = model.lab([c, m, y, k]);
worst = worst.max(craft_cms::math::delta_e76(got, want));