Skip to content

Commit ada0a90

Browse files
authored
Basic implementation of toGamut() (#199)
1 parent 09915b8 commit ada0a90

11 files changed

+1056
-8
lines changed

docs/api.md

+26-1
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ By default, the color is converted to `lch` to perform the clamping, but any col
376376
import { clampChroma } from 'culori';
377377

378378
clampChroma({ mode: 'oklch', l: 0.5, c: 0.16, h: 180 }, 'oklch');
379-
// => { mode: 'oklch', l: 0.5, c: 0.09, h: 180 }
379+
// { mode: 'oklch', l: 0.5, c: 0.09, h: 180 }
380380
```
381381

382382
In general, chroma clamping is more accurate and computationally simpler when performed in the color's original space, where possible. Here's some sample code that uses the color's own `mode` for color spaces containing a Chroma dimension, and `lch` otherwise:
@@ -391,6 +391,31 @@ If the chroma-finding algorithm fails to find a displayable color (which can hap
391391

392392
The function uses [the bisection method](https://en.wikipedia.org/wiki/Bisection_method) to speed up the search for the largest Chroma value. However, due to discontinuities in the CIELCh color space, the function is not guaranteed to return the optimal result. [See this discussion](https://github.com/d3/d3-color/issues/33) for details.
393393

394+
<a id="toGamut" href="#toGamut">#</a> **toGamut**(_dest = 'rgb'_, _mode = 'oklch'_, _delta = differenceEuclidean('oklch')_, _jnd = 0.02_) → _function (color | string)_
395+
396+
<span aria-label='Source:'>☞</span> [src/clamp.js]({{codebase}}/src/clamp.js)
397+
398+
Obtain a color that's in the `dest` gamut, by first converting it to the `mode` color space and then finding the largest chroma that's in gamut, similar to `clampChroma()`.
399+
400+
The color returned is in the `dest` color space.
401+
402+
```js
403+
import { p3, toGamut } from 'culori';
404+
405+
const color = 'lch(80% 150 60)';
406+
407+
p3(color);
408+
// ⇒ { mode: "p3", r: 1.229…, g: 0.547…, b: -0.073… }
409+
410+
const toP3 = toGamut('p3');
411+
toP3(color);
412+
// ⇒ { mode: "p3", r: 0.999…, g: 0.696…, b: 0.508… }
413+
```
414+
415+
To address the shortcomings of `clampChroma`, which can sometimes produce colors more desaturated than necessary, the test used in the binary search is replaced with "is color is roughly in gamut", by comparing the candidate to the clipped version (obtained with `clampGamut`). The test passes if the colors are not to dissimilar, judged by the `delta` color difference function and an associated `jnd` just-noticeable difference value.
416+
417+
The default arguments for this function correspond to [the gamut mapping algorithm](https://drafts.csswg.org/css-color/#css-gamut-mapping) defined in the CSS Color Module Level 4 spec, but the algorithm itself is slightly different.
418+
394419
## Interpolation
395420

396421
In any color space, colors occupy positions given by their values for each channel. Interpolating colors means tracing a line through the coordinates of these colors, and figuring out what colors reside on the line at various positions.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"scripts": {
5959
"prepare": "git config core.hooksPath .git-hooks",
6060
"test": "tape 'test/**/*.test.js' | tap-spec",
61+
"start": "npx esbuild --servedir=.",
6162
"build": "node build.js",
6263
"benchmark": "node benchmark/index.js",
6364
"prepublishOnly": "npm run lint && npm run build && npm run test",

src/clamp.js

+96-4
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ import { differenceEuclidean } from './difference.js';
55

66
const rgb = converter('rgb');
77
const fixup_rgb = c => {
8-
c.r = Math.max(0, Math.min(c.r, 1));
9-
c.g = Math.max(0, Math.min(c.g, 1));
10-
c.b = Math.max(0, Math.min(c.b, 1));
11-
return c;
8+
const res = {
9+
mode: c.mode,
10+
r: Math.max(0, Math.min(c.r, 1)),
11+
g: Math.max(0, Math.min(c.g, 1)),
12+
b: Math.max(0, Math.min(c.b, 1))
13+
};
14+
if (c.alpha !== undefined) {
15+
res.alpha = c.alpha;
16+
}
17+
return res;
1218
};
1319

1420
const inrange_rgb = c => {
@@ -153,3 +159,89 @@ export function clampChroma(color, mode = 'lch') {
153159
displayable(clamped) ? clamped : { ...clamped, c: _last_good_c }
154160
);
155161
}
162+
163+
/*
164+
Obtain a color that's in the `dest` gamut,
165+
by first converting it to the `mode` color space
166+
and then finding the largest chroma that's in gamut,
167+
similar to `clampChroma`.
168+
169+
The color returned is in the `dest` color space.
170+
171+
To address the shortcomings of `clampChroma`, which can
172+
sometimes produce colors more desaturated than necessary,
173+
the test used in the binary search is replaced with
174+
"is color is roughly in gamut", by comparing the candidate
175+
to the clipped version (obtained with `clampGamut`).
176+
The test passes if the colors are not to dissimilar,
177+
judged by the `delta` color difference function
178+
and an associated `jnd` just-noticeable difference value.
179+
180+
The default arguments for this function correspond to the
181+
gamut mapping algorithm defined in CSS Color Level 4:
182+
https://drafts.csswg.org/css-color/#css-gamut-mapping
183+
*/
184+
export function toGamut(
185+
dest = 'rgb',
186+
mode = 'oklch',
187+
delta = differenceEuclidean('oklch'),
188+
jnd = 0.02
189+
) {
190+
const destConv = converter(dest);
191+
192+
if (!getMode(dest).gamut) {
193+
return color => destConv(color);
194+
}
195+
196+
const inDestinationGamut = inGamut(dest);
197+
const clipToGamut = clampGamut(dest);
198+
199+
const ucs = converter(mode);
200+
const { ranges } = getMode(mode);
201+
202+
const gamutDef = getMode(dest);
203+
const White = destConv('white');
204+
const Black = destConv('black');
205+
206+
return color => {
207+
color = prepare(color);
208+
if (color === undefined) {
209+
return undefined;
210+
}
211+
const candidate = { ...ucs(color) };
212+
if (candidate.l >= ranges.l[1]) {
213+
const res = { ...White };
214+
if (color.alpha !== undefined) {
215+
res.alpha = color.alpha;
216+
}
217+
return res;
218+
}
219+
if (candidate.l <= ranges.l[0]) {
220+
const res = { ...Black };
221+
if (color.alpha !== undefined) {
222+
res.alpha = color.alpha;
223+
}
224+
return res;
225+
}
226+
if (inDestinationGamut(candidate)) {
227+
return destConv(candidate);
228+
}
229+
let start = 0;
230+
let end = candidate.c;
231+
let ε = (ranges.c[1] - ranges.c[0]) / 4000; // 0.0001 for oklch()
232+
let clipped = clipToGamut(candidate);
233+
while (end - start > ε) {
234+
candidate.c = (start + end) * 0.5;
235+
clipped = clipToGamut(candidate);
236+
if (
237+
inDestinationGamut(candidate) ||
238+
(jnd > 0 && delta(candidate, clipped) <= jnd)
239+
) {
240+
start = candidate.c;
241+
} else {
242+
end = candidate.c;
243+
}
244+
}
245+
return destConv(inDestinationGamut(candidate) ? candidate : clipped);
246+
};
247+
}

src/index-fn.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ export {
100100
inGamut,
101101
clampRgb,
102102
clampChroma,
103-
clampGamut
103+
clampGamut,
104+
toGamut
104105
} from './clamp.js';
105106
export { default as nearest } from './nearest.js';
106107
export { useMode, getMode, useParser, removeParser } from './modes.js';

src/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ export {
101101
inGamut,
102102
clampRgb,
103103
clampChroma,
104-
clampGamut
104+
clampGamut,
105+
toGamut
105106
} from './clamp.js';
106107
export { default as nearest } from './nearest.js';
107108
export { useMode, getMode, useParser, removeParser } from './modes.js';

test/api.test.js

+2
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ const API_FULL = [
210210
'serializeHex8',
211211
'serializeHsl',
212212
'serializeRgb',
213+
'toGamut',
213214
'unlerp',
214215
'useMode',
215216
'useParser',
@@ -449,6 +450,7 @@ const API_FN = [
449450
'serializeHex8',
450451
'serializeHsl',
451452
'serializeRgb',
453+
'toGamut',
452454
'unlerp',
453455
'useMode',
454456
'useParser',

test/clamp.test.js

+27-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
displayable,
55
inGamut,
66
clampGamut,
7-
formatCss
7+
formatCss,
8+
differenceEuclidean,
9+
toGamut
810
} from '../src/index.js';
911

1012
tape('RGB', function (test) {
@@ -134,3 +136,27 @@ tape('clampGamut()', t => {
134136

135137
t.end();
136138
});
139+
140+
tape('toGamut()', t => {
141+
t.equal(
142+
formatCss(toGamut('rgb')({ mode: 'oklch', l: 1.5, c: 0.2, h: 180 })),
143+
'color(srgb 1 1 1)',
144+
'white'
145+
);
146+
t.equal(
147+
formatCss(toGamut('rgb')({ mode: 'oklch', l: -1.5, c: 0.2, h: 180 })),
148+
'color(srgb 0 0 0)',
149+
'black'
150+
);
151+
t.equal(
152+
formatCss(toGamut('rgb')('color(--lch-d65 100 0 180)')),
153+
'color(srgb 0.9999999999999968 1.0000000000000016 0.9999999999999986)',
154+
'chroma = 0'
155+
);
156+
t.equal(
157+
formatCss(toGamut('p3')('lch(80% 150 60)')),
158+
'color(display-p3 0.9999999999999994 0.6969234154991887 0.5084794582132421)',
159+
'api docs example'
160+
);
161+
t.end();
162+
});

test/visual/gamut-map-newton.js

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
const OKLAB_TO_LMS = [
2+
[0.99999999845051981432, 0.39633779217376785678, 0.21580375806075880339],
3+
[1.0000000088817607767, -0.1055613423236563494, -0.063854174771705903402],
4+
[1.0000000546724109177, -0.089484182094965759684, -1.2914855378640917399]
5+
];
6+
7+
const LMS_TO_XYZ_D65 = [
8+
[1.2268798733741557, -0.5578149965554813, 0.28139105017721583],
9+
[-0.04057576262431372, 1.1122868293970594, -0.07171106666151701],
10+
[-0.07637294974672142, -0.4214933239627914, 1.5869240244272418]
11+
];
12+
13+
const XYZ_D65_TO_LRGB = [
14+
[3.2409699419045226, -1.537383177570094, -0.4986107602930034],
15+
[-0.9692436362808796, 1.8759675015077202, 0.04155505740717559],
16+
[0.05563007969699366, -0.20397695888897652, 1.0569715142428786]
17+
];
18+
19+
const LMS_TO_LRGB = multiplyMatrices(XYZ_D65_TO_LRGB, LMS_TO_XYZ_D65);
20+
21+
function multiplyMatrices(a, b) {
22+
let res = [[], [], []];
23+
for (let i = 0; i < 3; i++) {
24+
for (let j = 0; j < 3; j++) {
25+
res[i][j] =
26+
a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
27+
}
28+
}
29+
return res;
30+
}
31+
32+
function multiplyMatrixWithVector(m, v) {
33+
return [
34+
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
35+
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
36+
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2]
37+
];
38+
}
39+
40+
function multiplyComponentWise(arr1, arr2) {
41+
return arr1.map((it, i) => it * arr2[i]);
42+
}
43+
44+
function multiplyVectors(arr1, arr2) {
45+
return arr1.reduce((acc, curr, i) => {
46+
return acc + curr * arr2[i];
47+
}, 0);
48+
}
49+
50+
export function GamutMapNewton(original) {
51+
const zero_a_b = [0, original[1], original[2]];
52+
let alpha = 1;
53+
let res_oklab = original.slice();
54+
let res_rgb = multiplyMatrixWithVector(
55+
LMS_TO_LRGB,
56+
multiplyMatrixWithVector(OKLAB_TO_LMS, res_oklab).map(it => it ** 3)
57+
);
58+
for (let comp = 0; comp < 3; comp++) {
59+
if (res_rgb[comp] >= 0 && res_rgb[comp] <= 1) continue;
60+
let target = res_rgb[comp] > 1 ? 1 : 0;
61+
for (let iter = 0; iter < 6; iter++) {
62+
let residual =
63+
multiplyVectors(
64+
LMS_TO_LRGB[comp],
65+
multiplyMatrixWithVector(OKLAB_TO_LMS, res_oklab).map(
66+
it => it ** 3
67+
)
68+
) - target;
69+
if (Math.abs(residual) < 1e-15) break;
70+
let gradient = multiplyVectors(
71+
LMS_TO_LRGB[comp],
72+
multiplyComponentWise(
73+
multiplyMatrixWithVector(OKLAB_TO_LMS, res_oklab).map(
74+
it => 3 * it ** 2
75+
),
76+
multiplyMatrixWithVector(OKLAB_TO_LMS, zero_a_b)
77+
)
78+
);
79+
alpha -= residual / gradient;
80+
res_oklab[1] = alpha * original[1];
81+
res_oklab[2] = alpha * original[2];
82+
}
83+
res_rgb = multiplyMatrixWithVector(
84+
LMS_TO_LRGB,
85+
multiplyMatrixWithVector(OKLAB_TO_LMS, res_oklab).map(it => it ** 3)
86+
);
87+
}
88+
return [res_oklab, res_rgb];
89+
}

0 commit comments

Comments
 (0)