@texel/color 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/gamut.js +23 -5
- package/test/canvas-graph.js +5 -3
- package/test/check-gamut-epsilon.js +136 -0
- package/test/test-gamut-epsilon.js +0 -107
package/README.md
CHANGED
|
@@ -93,9 +93,7 @@ import { gamutMapOKLCH, sRGBGamut, sRGBLinear, OKLCH } from "@texel/color";
|
|
|
93
93
|
// gamut map to sRGB but return linear sRGB
|
|
94
94
|
const lrgb = gamutMapOKLCH(oklch, sRGBGamut, sRGBLinear);
|
|
95
95
|
|
|
96
|
-
// or gamut map to sRGB but return OKLCH
|
|
97
|
-
// note, when finally converting this back to sRGB you will likely
|
|
98
|
-
// want to clip the result to 0..1 bounds due to floating point loss
|
|
96
|
+
// or gamut map to sRGB but return OKLCH (does not perform RGB clip)
|
|
99
97
|
const lch = gamutMapOKLCH(oklch, sRGBGamut, OKLCH);
|
|
100
98
|
```
|
|
101
99
|
|
|
@@ -118,6 +116,8 @@ gamutMapOKLCH(oklch, sRGBGamut, sRGB, rgb, MapToL);
|
|
|
118
116
|
|
|
119
117
|
The `cusp` can also be passed as the last parameter, allowing for faster evaluation for known hues. See below for calculating the cusp.
|
|
120
118
|
|
|
119
|
+
> :Note: If you output to an OKLab-based target (OKLCH, OKHSL etc), the final step of RGB clipping will be skipped. This produces more predictable OKLab and OKLCH based results, but you will likely want to perform a final clampedRGB() step when converting to a displayable color.
|
|
120
|
+
|
|
121
121
|
#### `LC = findCuspOKLCH(a, b, gamut, out = [0, 0])`
|
|
122
122
|
|
|
123
123
|
Finds the 'cusp' of a given OKLab hue plane (denoted with normalized `a` and `b` values in OKLab space), returning the `[L, C]` (lightness and chroma). This is useful for pre-computing aspects of gamut mapping when you are working across a known hue:
|
package/package.json
CHANGED
package/src/gamut.js
CHANGED
|
@@ -288,11 +288,16 @@ export const gamutMapOKLCH = (
|
|
|
288
288
|
// convert oklch to base gamut space (i.e. linear sRGB)
|
|
289
289
|
convert(out, OKLCH, gamutSpaceBase, rgbVec);
|
|
290
290
|
|
|
291
|
-
// Note:
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
291
|
+
// Note: this is a possible area of performance improvement for some edge cases
|
|
292
|
+
// If you gamut map without clipping, you end up lying on the edge of the gamut,
|
|
293
|
+
// but in some cases very slightly out of gamut. Gamut mapping *again* is redundant
|
|
294
|
+
// as it will produce the same result; and in those cases, it should just skip straight
|
|
295
|
+
// to clipping. So, in theory, a small epsilon like 1e-7 would catch these and prevent redundant gamut mapping.
|
|
296
|
+
// See test/check-gamut-epsilon.js
|
|
297
|
+
// However, in practice, inputs to this function are likely not going to be already-mapped-but-not-clipped points,
|
|
298
|
+
// so we are talking about a very negligible improvement, and it is probably better to be accurate in as many cases
|
|
299
|
+
// as possible than to shave off a little time.
|
|
300
|
+
const RGB_CLIP_EPSILON = 0;
|
|
296
301
|
|
|
297
302
|
// check where the point lies in gamut space
|
|
298
303
|
if (!isRGBInGamut(rgbVec, RGB_CLIP_EPSILON)) {
|
|
@@ -320,6 +325,19 @@ export const gamutMapOKLCH = (
|
|
|
320
325
|
out[0] = lerp(LTarget, L, t);
|
|
321
326
|
out[1] *= t;
|
|
322
327
|
|
|
328
|
+
// Note: if requested targetSpace is base=OKLCH, we can return early.
|
|
329
|
+
// note this creates a potential difference compared to going to other targetSpaces:
|
|
330
|
+
// a) mapping to sRGB then finally converting back to OKLCH the result may be lossy in
|
|
331
|
+
// some coordinates that you might not expect, e.g. hue loss when hue should be unchanged
|
|
332
|
+
// during gamut mapping, due to conversion / floating point math
|
|
333
|
+
// b) if you map to sRGB it will perform clipping, but if you map to OKLCH then no sRGB clipping
|
|
334
|
+
// will be applied. The OKLCH result is _basically_ in gamut, but not exactly; you'll need to clip at final stage.
|
|
335
|
+
// I've documented this behaviour in the readme.
|
|
336
|
+
const targetSpaceBase = targetSpace.base ?? targetSpace;
|
|
337
|
+
if (targetSpaceBase == OKLab) {
|
|
338
|
+
return convert(out, OKLCH, targetSpace, out);
|
|
339
|
+
}
|
|
340
|
+
|
|
323
341
|
// now that we have a LCH that sits on (or nearly on) the gamut, convert again to linear space
|
|
324
342
|
convert(out, OKLCH, gamutSpaceBase, rgbVec);
|
|
325
343
|
}
|
package/test/canvas-graph.js
CHANGED
|
@@ -13,12 +13,13 @@ import {
|
|
|
13
13
|
constrainAngle,
|
|
14
14
|
floatToByte,
|
|
15
15
|
isRGBInGamut,
|
|
16
|
+
clampedRGB,
|
|
16
17
|
} from "../src/index.js";
|
|
17
18
|
import arrayAlmostEqual from "./almost-equal.js";
|
|
18
19
|
|
|
19
20
|
const settings = {
|
|
20
21
|
dimensions: [768, 768],
|
|
21
|
-
animate:
|
|
22
|
+
animate: false,
|
|
22
23
|
playbackRate: "throttle",
|
|
23
24
|
fps: 2,
|
|
24
25
|
attributes: {
|
|
@@ -34,7 +35,8 @@ const sketch = ({ width, height }) => {
|
|
|
34
35
|
|
|
35
36
|
context.fillStyle = "gray";
|
|
36
37
|
context.fillRect(0, 0, width, height);
|
|
37
|
-
const H =
|
|
38
|
+
const H = 264.1;
|
|
39
|
+
// const H = constrainAngle((frame * 45) / 2);
|
|
38
40
|
|
|
39
41
|
// console.time("map");
|
|
40
42
|
// console.profile("map");
|
|
@@ -123,7 +125,7 @@ const sketch = ({ width, height }) => {
|
|
|
123
125
|
context.lineTo(...LCtoXY(mapped));
|
|
124
126
|
context.stroke();
|
|
125
127
|
context.globalAlpha = 1;
|
|
126
|
-
drawLCPoint(context, mapped.slice(0, 2), radius, "white");
|
|
128
|
+
drawLCPoint(context, mapped.slice(0, 2), radius / 4, "white");
|
|
127
129
|
} else {
|
|
128
130
|
drawLCPoint(context, lc, radius);
|
|
129
131
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// When gamut mapping with OKLCH approximation,
|
|
2
|
+
// the resulting points do not always lie exactly in gamut.
|
|
3
|
+
// The same may be true of OKHSL to RGB spaces.
|
|
4
|
+
// Let's figure out how far away they are:
|
|
5
|
+
// if a given point is under this threshold, gamut mapping
|
|
6
|
+
// will be redundant as it will just produce the same epsilon.
|
|
7
|
+
// This value is used in gamut.js as the RGB_CLIP_EPSILON
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
clampedRGB,
|
|
11
|
+
convert,
|
|
12
|
+
degToRad,
|
|
13
|
+
findCuspOKLCH,
|
|
14
|
+
findGamutIntersectionOKLCH,
|
|
15
|
+
gamutMapOKLCH,
|
|
16
|
+
isRGBInGamut,
|
|
17
|
+
lerp,
|
|
18
|
+
MapToCuspL,
|
|
19
|
+
OKLCH,
|
|
20
|
+
sRGB,
|
|
21
|
+
sRGBGamut,
|
|
22
|
+
sRGBLinear,
|
|
23
|
+
} from "../src/index.js";
|
|
24
|
+
|
|
25
|
+
const gamut = sRGBGamut;
|
|
26
|
+
const target = gamut.space.base; // linear form
|
|
27
|
+
|
|
28
|
+
// slice the plane into a square
|
|
29
|
+
const slices = 100;
|
|
30
|
+
let avgDelta = 0;
|
|
31
|
+
let avgCount = 0;
|
|
32
|
+
let minDelta = Infinity;
|
|
33
|
+
let maxDelta = -Infinity;
|
|
34
|
+
|
|
35
|
+
// a very small number which still catches many gamut-mapped points
|
|
36
|
+
// but produces very little difference in practical and visual results
|
|
37
|
+
const RGB_CLIP_EPSILON = 0.0000001;
|
|
38
|
+
let totalPointsOutOfGamut = 0;
|
|
39
|
+
let totalPointsUnderEpsilon = 0;
|
|
40
|
+
let totalPointsUnderEpsilonBeforeMapping = 0;
|
|
41
|
+
|
|
42
|
+
// this particular hue is a little funky
|
|
43
|
+
// https://github.com/color-js/color.js/issues/81
|
|
44
|
+
// it produces out of gamut sRGB, however, the oklab gamut approximation seems to handle it fine
|
|
45
|
+
const hue = 264.1;
|
|
46
|
+
const lightness = 0.4;
|
|
47
|
+
for (let chroma = 0.22; chroma < 0.285; chroma += 0.001) {
|
|
48
|
+
const oklch = [lightness, chroma, hue];
|
|
49
|
+
const rgb = convert(oklch, OKLCH, sRGBLinear);
|
|
50
|
+
if (!isRGBInGamut(rgb, 0)) {
|
|
51
|
+
const mappedOKLCH = gamutMapWithoutClipOKLCH(oklch);
|
|
52
|
+
const mappedRGB = convert(mappedOKLCH, OKLCH, sRGBLinear);
|
|
53
|
+
const delta = clipDelta(mappedRGB);
|
|
54
|
+
if (!delta.every((n) => n == 0)) console.log("hue", hue, "delta", delta);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// test all hue planes Nº difference apart
|
|
59
|
+
// we will see some gamut mapped points still do not lie exactly in gamut
|
|
60
|
+
for (let H = 0; H < 360; H += 0.5) {
|
|
61
|
+
for (let y = 0; y < slices; y++) {
|
|
62
|
+
for (let x = 0; x < slices; x++) {
|
|
63
|
+
const u = x / (slices - 1);
|
|
64
|
+
const v = y / (slices - 1);
|
|
65
|
+
const L = 1 - v;
|
|
66
|
+
const C = u * 0.4;
|
|
67
|
+
|
|
68
|
+
// try conversion
|
|
69
|
+
let rgbl = convert([L, C, H], OKLCH, target);
|
|
70
|
+
|
|
71
|
+
// not exactly in space
|
|
72
|
+
if (!isRGBInGamut(rgbl, 0)) {
|
|
73
|
+
// check epsilons
|
|
74
|
+
if (isRGBInGamut(rgbl, RGB_CLIP_EPSILON)) {
|
|
75
|
+
totalPointsUnderEpsilonBeforeMapping++;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const oklch = gamutMapWithoutClipOKLCH([L, C, H]);
|
|
79
|
+
rgbl = convert(oklch, OKLCH, target);
|
|
80
|
+
|
|
81
|
+
const [dr, dg, db] = clipDelta(rgbl);
|
|
82
|
+
const avg = (dr + dg + db) / 3;
|
|
83
|
+
const min = Math.min(dr, dg, db);
|
|
84
|
+
const max = Math.max(dr, dg, db);
|
|
85
|
+
avgDelta += avg;
|
|
86
|
+
avgCount++;
|
|
87
|
+
minDelta = Math.min(min, minDelta);
|
|
88
|
+
maxDelta = Math.max(max, maxDelta);
|
|
89
|
+
|
|
90
|
+
totalPointsOutOfGamut++;
|
|
91
|
+
if (isRGBInGamut(rgbl, RGB_CLIP_EPSILON)) {
|
|
92
|
+
totalPointsUnderEpsilon++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function clipDelta(rgb) {
|
|
100
|
+
const clipped = clampedRGB(rgb);
|
|
101
|
+
const dr = Math.abs(rgb[0] - clipped[0]);
|
|
102
|
+
const dg = Math.abs(rgb[1] - clipped[1]);
|
|
103
|
+
const db = Math.abs(rgb[2] - clipped[2]);
|
|
104
|
+
return [dr, dg, db];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function gamutMapWithoutClipOKLCH(oklch) {
|
|
108
|
+
const [L, C, H] = oklch;
|
|
109
|
+
// we aren't in gamut, so let's map toward it
|
|
110
|
+
const hueAngle = degToRad(H);
|
|
111
|
+
const aNorm = Math.cos(hueAngle);
|
|
112
|
+
const bNorm = Math.sin(hueAngle);
|
|
113
|
+
|
|
114
|
+
const out = [L, C, H];
|
|
115
|
+
// choose our strategy
|
|
116
|
+
const cusp = findCuspOKLCH(aNorm, bNorm, gamut);
|
|
117
|
+
const LTarget = MapToCuspL(out, cusp);
|
|
118
|
+
|
|
119
|
+
let t = findGamutIntersectionOKLCH(aNorm, bNorm, L, C, LTarget, cusp, gamut);
|
|
120
|
+
out[0] = lerp(LTarget, L, t);
|
|
121
|
+
out[1] *= t;
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
avgDelta /= avgCount;
|
|
126
|
+
|
|
127
|
+
console.log("Min Epsilon:", minDelta);
|
|
128
|
+
console.log("Max Epsilon:", maxDelta);
|
|
129
|
+
console.log("Average Epsilon:", avgDelta);
|
|
130
|
+
console.log("Compare against epsilon:", RGB_CLIP_EPSILON);
|
|
131
|
+
console.log("Total points out of gamut:", totalPointsOutOfGamut);
|
|
132
|
+
console.log(
|
|
133
|
+
"Total points under epsilon (before map):",
|
|
134
|
+
totalPointsUnderEpsilonBeforeMapping
|
|
135
|
+
);
|
|
136
|
+
console.log("Total points under epsilon (after map):", totalPointsUnderEpsilon);
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
// When gamut mapping with OKLCH approximation,
|
|
2
|
-
// the resulting points do not always lie exactly in gamut.
|
|
3
|
-
// The same may be true of OKHSL to RGB spaces.
|
|
4
|
-
// Let's figure out how far away they are:
|
|
5
|
-
// if a given point is under this threshold, gamut mapping
|
|
6
|
-
// will be redundant as it will just produce the same epsilon.
|
|
7
|
-
// This value is used in gamut.js as the RGB_CLIP_EPSILON
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
A98RGBGamut,
|
|
11
|
-
clampedRGB,
|
|
12
|
-
convert,
|
|
13
|
-
degToRad,
|
|
14
|
-
DisplayP3Gamut,
|
|
15
|
-
findCuspOKLCH,
|
|
16
|
-
findGamutIntersectionOKLCH,
|
|
17
|
-
gamutMapOKLCH,
|
|
18
|
-
isRGBInGamut,
|
|
19
|
-
lerp,
|
|
20
|
-
MapToCuspL,
|
|
21
|
-
OKLCH,
|
|
22
|
-
Rec2020Gamut,
|
|
23
|
-
sRGBGamut,
|
|
24
|
-
} from "../src/index.js";
|
|
25
|
-
|
|
26
|
-
const huePlanes = Array(360)
|
|
27
|
-
.fill()
|
|
28
|
-
.map((_, i) => i);
|
|
29
|
-
const gamut = sRGBGamut;
|
|
30
|
-
const target = gamut.space.base; // linear form
|
|
31
|
-
|
|
32
|
-
// slice the plane into a square
|
|
33
|
-
const slices = 100;
|
|
34
|
-
let avgDelta = 0;
|
|
35
|
-
let avgCount = 0;
|
|
36
|
-
let minDelta = Infinity;
|
|
37
|
-
let maxDelta = -Infinity;
|
|
38
|
-
|
|
39
|
-
const EPSILON = 0.000074;
|
|
40
|
-
let totalPointsOutOfGamut = 0;
|
|
41
|
-
let totalPointsUnderEpsilon = 0;
|
|
42
|
-
|
|
43
|
-
for (let H = 0; H < 360; H += 0.5) {
|
|
44
|
-
for (let y = 0; y < slices; y++) {
|
|
45
|
-
for (let x = 0; x < slices; x++) {
|
|
46
|
-
const u = x / (slices - 1);
|
|
47
|
-
const v = y / (slices - 1);
|
|
48
|
-
const L = 1 - v;
|
|
49
|
-
const C = u * 0.4;
|
|
50
|
-
|
|
51
|
-
// try conversion
|
|
52
|
-
const rgbl = convert([L, C, H], OKLCH, target);
|
|
53
|
-
|
|
54
|
-
// not exactly in space
|
|
55
|
-
if (!isRGBInGamut(rgbl, 0)) {
|
|
56
|
-
// we aren't in gamut, so let's map toward it
|
|
57
|
-
const hueAngle = degToRad(H);
|
|
58
|
-
const aNorm = Math.cos(hueAngle);
|
|
59
|
-
const bNorm = Math.sin(hueAngle);
|
|
60
|
-
|
|
61
|
-
const out = [L, C, H];
|
|
62
|
-
// choose our strategy
|
|
63
|
-
const cusp = findCuspOKLCH(aNorm, bNorm, gamut);
|
|
64
|
-
const LTarget = MapToCuspL(out, cusp);
|
|
65
|
-
|
|
66
|
-
let t = findGamutIntersectionOKLCH(
|
|
67
|
-
aNorm,
|
|
68
|
-
bNorm,
|
|
69
|
-
L,
|
|
70
|
-
C,
|
|
71
|
-
LTarget,
|
|
72
|
-
cusp,
|
|
73
|
-
gamut
|
|
74
|
-
);
|
|
75
|
-
out[0] = lerp(LTarget, L, t);
|
|
76
|
-
out[1] *= t;
|
|
77
|
-
|
|
78
|
-
// convert again to rgb linear
|
|
79
|
-
const rgbl = convert(out, OKLCH, target);
|
|
80
|
-
const clipped = clampedRGB(rgbl);
|
|
81
|
-
const dr = Math.abs(rgbl[0] - clipped[0]);
|
|
82
|
-
const dg = Math.abs(rgbl[1] - clipped[1]);
|
|
83
|
-
const db = Math.abs(rgbl[2] - clipped[2]);
|
|
84
|
-
const avg = (dr + dg + db) / 3;
|
|
85
|
-
const min = Math.min(dr, dg, db);
|
|
86
|
-
const max = Math.max(dr, dg, db);
|
|
87
|
-
avgDelta += avg;
|
|
88
|
-
avgCount++;
|
|
89
|
-
minDelta = Math.min(min, minDelta);
|
|
90
|
-
maxDelta = Math.max(max, maxDelta);
|
|
91
|
-
|
|
92
|
-
totalPointsOutOfGamut++;
|
|
93
|
-
if (isRGBInGamut(rgbl, EPSILON)) {
|
|
94
|
-
totalPointsUnderEpsilon++;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
avgDelta /= avgCount;
|
|
101
|
-
|
|
102
|
-
console.log("Min Epsilon:", minDelta);
|
|
103
|
-
console.log("Max Epsilon:", maxDelta);
|
|
104
|
-
console.log("Average Epsilon:", avgDelta);
|
|
105
|
-
console.log("Compare against epsilon:", EPSILON);
|
|
106
|
-
console.log("Total points out of gamut:", totalPointsOutOfGamut);
|
|
107
|
-
console.log("Total points under epsilon:", totalPointsUnderEpsilon);
|