Skip to content

Commit 621e655

Browse files
committed
Add an Oklch chroma-reduction gamut mapping function
To start thinking about what gamut mapping might look like. This sticks close to the sample pseudocode provided in [CSS Color Module Level 4](https://www.w3.org/TR/css-color-4/#binsearch). Note that, instead of mapping from an origin color space to a destination space, this maps colors that are "out-of-gamut" into a color space's natural gamut (using Color's ability to represent those "out-of-gamut" colors.)
1 parent e824f67 commit 621e655

File tree

3 files changed

+162
-0
lines changed

3 files changed

+162
-0
lines changed

color/Cargo.toml

+4
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ default = ["std"]
2323
std = []
2424
libm = ["dep:libm"]
2525

26+
# Makes mapping functions available for fitting colors into the natural gamuts
27+
# of color spaces.
28+
gamut_map = []
29+
2630
[dependencies]
2731

2832
[dependencies.libm]

color/src/gamut_map.rs

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
//! Gamut mapping operations
2+
//!
3+
//! There are many possible ways to map colors outside of a color space's natural gamut to colors
4+
//! inside the gamut. The mathematically simplest operation is implemented by [`ColorSpace::clip`].
5+
//! Some methods are perceptually better than others; for example, preserving a color's perceived
6+
//! hue when mapping is generally desirable. Depending on the use-case, other factors may be
7+
//! relevant; for example, when working with an individual color, perhaps it should be mapped to
8+
//! the closest color in the gamut. In contrast, when compressing high dynamic range photographs
9+
//! into a gamut, perhaps the relationship between colors is more important than sticking as close
10+
//! as possible to the original colors.
11+
//!
12+
//! # Examples
13+
//!
14+
//! ```rust
15+
//! use color::{gamut_map, ColorSpace, LinearSrgb};
16+
//!
17+
//! // A linear sRGB color with the red color component outside of the natural gamut.
18+
//! let color = [1.1, 0.5, 0.0];
19+
//! assert_ne!(LinearSrgb::clip(color), color);
20+
//!
21+
//! let mapped = gamut_map::reduce_chroma::<LinearSrgb>(color, 0.02);
22+
//!
23+
//! // The mapped color is inside the gamut.
24+
//! assert_eq!(LinearSrgb::clip(mapped), mapped);
25+
//! ```
26+
27+
use crate::{ColorSpace, ColorSpaceTag, Oklab, Oklch};
28+
29+
/// Fits `src` into the natural gamut of the color space, under a relative colorimetric rendering
30+
/// intent, by reducing the color's chroma in the [`Oklch`] color space.
31+
///
32+
/// This works on individual colors. When used to map multiple colors into the color space's gamut,
33+
/// the relationship between those colors may become distorted.
34+
///
35+
/// The color's chroma is reduced until the [clipped](ColorSpace::clip) color (which always fits
36+
/// inside the gamut) is *not noticeably different* from the current chroma-reduced color. This
37+
/// helps prevent excessive chroma reduction that might otherwise result due to the concativity of
38+
/// the gamut boundary. Colors are not noticeably different if their *DeltaEOK* is less than
39+
/// `jnd`.
40+
///
41+
/// A common value for `jnd` is 0.02.
42+
pub fn reduce_chroma<CS: ColorSpace>(src: [f32; 3], jnd: f32) -> [f32; 3] {
43+
// This implements the binary search gamut-finding algorithm from CSS Color Module 4. See:
44+
// https://www.w3.org/TR/css-color-4/#binsearch
45+
const EPSILON: f32 = 0.000_1;
46+
47+
/// DeltaEOK squared between a color in `CS` space and `Oklch` space
48+
fn delta_eok2<CS: ColorSpace>(cs: [f32; 3], oklch: [f32; 3]) -> f32 {
49+
let src1 = CS::convert::<Oklab>(cs);
50+
let src2 = Oklch::convert::<Oklab>(oklch);
51+
(src1[0] - src2[0]).powi(2) + (src1[1] - src2[1]).powi(2) + (src1[2] - src2[2]).powi(2)
52+
}
53+
54+
// Short-circuit unbounded color spaces.
55+
if matches!(
56+
CS::TAG,
57+
Some(ColorSpaceTag::Oklch | ColorSpaceTag::Oklab | ColorSpaceTag::XyzD65)
58+
) {
59+
return src;
60+
}
61+
62+
debug_assert!(jnd > 0.);
63+
let jnd2 = jnd * jnd;
64+
65+
// The current color in Oklch.
66+
let [l, mut c, h] = CS::convert::<Oklch>(src);
67+
68+
if l < 0. {
69+
return Oklch::convert::<CS>([0., 0., 0.]);
70+
} else if l > 1. {
71+
return Oklch::convert::<CS>([1., 0., 0.]);
72+
}
73+
74+
// The clipped color in CS.
75+
let mut clipped = CS::clip(src);
76+
77+
if delta_eok2::<CS>(clipped, [l, c, h]) < jnd2 {
78+
return clipped;
79+
}
80+
81+
let mut min = 0.;
82+
let mut max = c;
83+
let mut min_in_gamut = true;
84+
85+
while max - min > EPSILON {
86+
c = 0.5 * (min + max);
87+
let current_cs = Oklch::convert::<CS>([l, c, h]);
88+
let clipped_ = CS::clip(current_cs);
89+
90+
if min_in_gamut && clipped_ == current_cs {
91+
min = c;
92+
continue;
93+
}
94+
95+
clipped = clipped_;
96+
let err2 = delta_eok2::<CS>(clipped, [l, c, h]);
97+
if err2 < jnd2 {
98+
if jnd2 - err2 < EPSILON * EPSILON {
99+
return clipped;
100+
} else {
101+
min_in_gamut = false;
102+
min = c;
103+
}
104+
} else {
105+
max = c;
106+
}
107+
}
108+
109+
clipped
110+
}
111+
112+
#[cfg(test)]
113+
mod tests {
114+
use crate::{ColorSpace, Oklab, Oklch, Srgb};
115+
116+
use super::reduce_chroma;
117+
118+
fn deltaeok<CS: ColorSpace>(src1: [f32; 3], src2: [f32; 3]) -> f32 {
119+
let src1 = CS::convert::<Oklab>(src1);
120+
let src2 = CS::convert::<Oklab>(src2);
121+
122+
((src1[0] - src2[0]).powi(2) + (src1[1] - src2[1]).powi(2) + (src1[2] - src2[2]).powi(2))
123+
.sqrt()
124+
}
125+
126+
#[test]
127+
fn reduce_chroma_roundtrip_in_gamut() {
128+
const EPSILON: f32 = 0.000_000_1;
129+
130+
let components = [0.0, 1.0, 0.5, 0.001, 0.999];
131+
for r in components {
132+
for g in components {
133+
for b in components {
134+
let color = [r, g, b];
135+
let mapped = reduce_chroma::<Srgb>(color, 0.002);
136+
137+
// The original color must be returned modulo roundoff errors.
138+
assert!(deltaeok::<Srgb>(color, mapped) < EPSILON);
139+
140+
// The mapped color must still be inside the gamut (and not be nudged out,
141+
// e.g., due to numerical stability).
142+
assert_eq!(Srgb::clip(mapped), color);
143+
}
144+
}
145+
}
146+
}
147+
148+
#[test]
149+
fn reduce_chroma_known_reference() {
150+
// Add some more reference values
151+
let srgb = Oklch::convert::<Srgb>([0.5, 0.205, 230.]);
152+
let color = reduce_chroma::<Srgb>(srgb, 0.02);
153+
assert!(deltaeok::<Srgb>(color, [0., 109. / 255., 145. / 255.]) < 0.02);
154+
}
155+
}

color/src/lib.rs

+3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ mod parse;
3030
mod serialize;
3131
mod tagged;
3232

33+
#[cfg(feature = "gamut_map")]
34+
pub mod gamut_map;
35+
3336
#[cfg(all(not(feature = "std"), not(test)))]
3437
mod floatfuncs;
3538

0 commit comments

Comments
 (0)