@texel/color 1.0.1 → 1.0.3
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 +25 -7
- package/package.json +2 -1
- package/src/gamut.js +8 -12
- package/test/bench-culori.js +148 -0
- package/test/bench-size.js +10 -1
- package/test/test-gamut-epsilon.js +107 -0
package/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
-
A minimal and modern color library for JavaScript.
|
|
5
|
+
A minimal and modern color library for JavaScript. Especially useful for real-time applications, generative art, and graphics on the web.
|
|
6
6
|
|
|
7
7
|
- Features: fast color conversion, color difference, gamut mapping, and serialization
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
8
|
+
- Optimized for speed: approx 20-125 times faster than [Colorjs.io](https://colorjs.io/) (see [benchmarks](#benchmarks))
|
|
9
|
+
- Optimized for low memory and minimal allocations: no arrays or objects are created within conversion and gamut mapping functions
|
|
10
|
+
- Optimized for compact bundles: zero dependencies, and unused color spaces can be automatically tree-shaked away for small sizes (e.g. ~3.5kb minified if you only require OKLCH to sRGB conversion)
|
|
11
|
+
- Optimized for accuracy: [high precision](#accuracy) color space matrices
|
|
12
12
|
- Focused on a minimal and modern set of color spaces:
|
|
13
13
|
- xyz (D65), xyz-d50, oklab, oklch, okhsv, okhsl, srgb, srgb-linear, display-p3, display-p3-linear, rec2020, rec2020-linear, a98-rgb, a98-rgb-linear, prophoto-rgb, prophoto-rgb-linear
|
|
14
14
|
|
|
@@ -307,7 +307,9 @@ sRGB.toBase(in_sRGB, out_linear_sRGB); // linear to gamma transfer function
|
|
|
307
307
|
|
|
308
308
|
### Why another library?
|
|
309
309
|
|
|
310
|
-
[Colorjs](https://colorjs.io/) is fantastic and perhaps the current leading standard in JavaScript, but it's not very practical for creative coding and real-time web applications, where the requirements are often (1) leaner codebases, (2) highly optimized, and (3) minimal GC thrashing.
|
|
310
|
+
[Colorjs](https://colorjs.io/) is fantastic and perhaps the current leading standard in JavaScript, but it's not very practical for creative coding and real-time web applications, where the requirements are often (1) leaner codebases, (2) highly optimized, and (3) minimal GC thrashing.
|
|
311
|
+
|
|
312
|
+
Colorjs, and simialrly, [Culori](https://culorijs.org/)), are focused on matching CSS spec, which means it will very likely continue to grow in complexity over time, and performance will often be marred (for example, `@texel/color` cusp intersection gamut mapping is ~125 times faster than Colorjs and ~60 times faster than culori).
|
|
311
313
|
|
|
312
314
|
There are many other options such as [color-space](https://www.npmjs.com/package/color-space) or [color-convert](https://www.npmjs.com/package/color-convert), however, these do not support modern spacse such as OKLab and OKHSL, and/or have dubious levels of accuracy (many libraries, for example, do not distinguish between D50 and D65 whitepoints in XYZ).
|
|
313
315
|
|
|
@@ -337,6 +339,7 @@ If you think the matrices or accuracy could be improved, please open a PR.
|
|
|
337
339
|
There are a few benchmarks inside [test](./test):
|
|
338
340
|
|
|
339
341
|
- [bench-colorjs.js](./test/bench-colorjs.js) - run with `npm run bench` to compare against colorjs
|
|
342
|
+
- [bench-culori.js](./test/bench-colorjs.js) - run with node to compare against [culori](https://culorijs.org/)
|
|
340
343
|
- [bench-node.js](./test/bench-node.js) - run with `npm run bench:node` to get a node profile
|
|
341
344
|
- [bench-size.js](./test/bench-size.js) - run with `npm run bench:size` to get a small bundle size with esbuild
|
|
342
345
|
|
|
@@ -359,6 +362,21 @@ Ours: 82.04 ms
|
|
|
359
362
|
Speedup: 23.6x faster
|
|
360
363
|
```
|
|
361
364
|
|
|
365
|
+
And against culori:
|
|
366
|
+
|
|
367
|
+
```
|
|
368
|
+
Testing with input type: Random Samling in OKLab L Planes
|
|
369
|
+
Conversion OKLCH to P3 --
|
|
370
|
+
Culori: 43.30 ms
|
|
371
|
+
Ours: 12.83 ms
|
|
372
|
+
Speedup: 3.4x faster
|
|
373
|
+
|
|
374
|
+
Gamut Mapping OKLCH to P3 Gamut --
|
|
375
|
+
Culori: 1588.62 ms
|
|
376
|
+
Ours: 23.05 ms
|
|
377
|
+
Speedup: 68.9x faster
|
|
378
|
+
```
|
|
379
|
+
|
|
362
380
|
### Running Locally
|
|
363
381
|
|
|
364
382
|
Clone, `npm install`, then `npm run` to list the available scripts, or `npm t` to run the tests.
|
|
@@ -373,4 +391,4 @@ This library was made possible due to the excellent prior work by many developer
|
|
|
373
391
|
|
|
374
392
|
## License
|
|
375
393
|
|
|
376
|
-
MIT, see [LICENSE.md](
|
|
394
|
+
MIT, see [LICENSE.md](https://github.com/texel-org/color/blob/main/LICENSE.md) for details.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@texel/color",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "a minimal and modern color library",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"canvas-sketch": "^0.7.7",
|
|
14
14
|
"canvas-sketch-cli": "^1.11.21",
|
|
15
15
|
"colorjs.io": "^0.5.2",
|
|
16
|
+
"culori": "^4.0.1",
|
|
16
17
|
"esbuild": "^0.23.0",
|
|
17
18
|
"faucet": "^0.0.4",
|
|
18
19
|
"pako": "^2.1.0",
|
package/src/gamut.js
CHANGED
|
@@ -288,8 +288,14 @@ 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: if the distance lies under this threshold, it's unlikely
|
|
292
|
+
// that gamut mapping will do anything, and it may simply produce a new
|
|
293
|
+
// point that lies the same or similar distance away from the gamut edge
|
|
294
|
+
// see test/test-gamut-epsilon.js
|
|
295
|
+
const RGB_CLIP_EPSILON = 0.000074;
|
|
296
|
+
|
|
291
297
|
// check where the point lies in gamut space
|
|
292
|
-
if (!isRGBInGamut(rgbVec,
|
|
298
|
+
if (!isRGBInGamut(rgbVec, RGB_CLIP_EPSILON)) {
|
|
293
299
|
// we aren't in gamut, so let's map toward it
|
|
294
300
|
const L = out[0];
|
|
295
301
|
const C = out[1];
|
|
@@ -314,17 +320,7 @@ export const gamutMapOKLCH = (
|
|
|
314
320
|
out[0] = lerp(LTarget, L, t);
|
|
315
321
|
out[1] *= t;
|
|
316
322
|
|
|
317
|
-
//
|
|
318
|
-
// note this creates a potential difference compared to other targetSpaces, which
|
|
319
|
-
// will be clipped in RGB before converting to the target space.
|
|
320
|
-
// however, due to floating point arithmetic, a user doing OKLCH -> RGB will still
|
|
321
|
-
// need to clip the result again anyways, so perhaps this difference is negligible.
|
|
322
|
-
const targetSpaceBase = targetSpace.base ?? targetSpaceBase;
|
|
323
|
-
if (targetSpaceBase == OKLab) {
|
|
324
|
-
return convert(out, OKLCH, targetSpace, out);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// now that we have a LCH that sits on the gamut, convert again to linear space
|
|
323
|
+
// now that we have a LCH that sits on (or nearly on) the gamut, convert again to linear space
|
|
328
324
|
convert(out, OKLCH, gamutSpaceBase, rgbVec);
|
|
329
325
|
}
|
|
330
326
|
// clip the linear RGB to 0..1 range
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
convert,
|
|
3
|
+
OKLCH,
|
|
4
|
+
sRGB,
|
|
5
|
+
sRGBGamut,
|
|
6
|
+
listColorSpaces,
|
|
7
|
+
DisplayP3Gamut,
|
|
8
|
+
DisplayP3,
|
|
9
|
+
gamutMapOKLCH,
|
|
10
|
+
constrainAngle,
|
|
11
|
+
findCuspOKLCH,
|
|
12
|
+
degToRad,
|
|
13
|
+
MapToCuspL,
|
|
14
|
+
OKHSL,
|
|
15
|
+
OKLab,
|
|
16
|
+
OKHSLToOKLab,
|
|
17
|
+
} from "../src/index.js";
|
|
18
|
+
|
|
19
|
+
import { p3, toGamut, oklch, okhsl, converter } from "culori";
|
|
20
|
+
|
|
21
|
+
const N = 256 * 256;
|
|
22
|
+
const gamut = DisplayP3Gamut;
|
|
23
|
+
const target = gamut.space;
|
|
24
|
+
|
|
25
|
+
// get N OKLCH in-gamut pixels by sampling uniformly from OKHSL cube
|
|
26
|
+
const oklchPixelsInGamut = Array(N)
|
|
27
|
+
.fill()
|
|
28
|
+
.map((_, i, lst) => {
|
|
29
|
+
const t = i / (lst.length - 1);
|
|
30
|
+
// note if we use the standard OKHSL space, it is bound to sRGB gamut...
|
|
31
|
+
// so instead we use the OKHSLToOKLab function and pass P3 gamut
|
|
32
|
+
const okhsl = [t * 360, t, t];
|
|
33
|
+
const oklab = OKHSLToOKLab(okhsl, gamut);
|
|
34
|
+
return convert(oklab, OKLab, OKLCH);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// get N OKLCH pixels by sampling OKLab, many will be out of srgb gamut
|
|
38
|
+
const oklchPixelsRandom = Array(N)
|
|
39
|
+
.fill()
|
|
40
|
+
.map((_, i, lst) => {
|
|
41
|
+
const t = i / (lst.length - 1);
|
|
42
|
+
return convert([t, t * 2 - 1, t * 2 - 1], OKLab, OKLCH);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const toP3Gamut = toGamut("p3", "oklch");
|
|
46
|
+
// same perf as p3() it seems
|
|
47
|
+
// const p3Converter = converter("p3");
|
|
48
|
+
|
|
49
|
+
test(oklchPixelsInGamut, "Random Sampling in P3 Gamut");
|
|
50
|
+
test(oklchPixelsRandom, "Random Sampling in OKLab L Planes");
|
|
51
|
+
test(oklchPixelsRandom, "Random Sampling in OKLab L Planes", true);
|
|
52
|
+
|
|
53
|
+
function test(inputPixelsOKLCH, label, fixedCusp) {
|
|
54
|
+
let cuspMap;
|
|
55
|
+
if (fixedCusp) {
|
|
56
|
+
cuspMap = new Array(360).fill(null);
|
|
57
|
+
inputPixelsOKLCH = inputPixelsOKLCH.map((oklch) => {
|
|
58
|
+
const H = constrainAngle(Math.round(oklch[2]));
|
|
59
|
+
oklch = oklch.slice();
|
|
60
|
+
oklch[2] = H;
|
|
61
|
+
if (!cuspMap[H]) {
|
|
62
|
+
const Hr = degToRad(H);
|
|
63
|
+
const a = Math.cos(Hr);
|
|
64
|
+
const b = Math.sin(Hr);
|
|
65
|
+
cuspMap[H] = findCuspOKLCH(a, b, gamut);
|
|
66
|
+
}
|
|
67
|
+
return oklch;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log(
|
|
72
|
+
"Testing with input type: %s%s",
|
|
73
|
+
label,
|
|
74
|
+
fixedCusp ? " (Fixed Cusp)" : ""
|
|
75
|
+
);
|
|
76
|
+
const culoriInputsOKLCH = inputPixelsOKLCH.map(([l, c, h]) => {
|
|
77
|
+
return {
|
|
78
|
+
mode: "oklch",
|
|
79
|
+
l,
|
|
80
|
+
c,
|
|
81
|
+
h,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
let now, elapsedCulori, elapsedOurs;
|
|
86
|
+
let tmp = [0, 0, 0];
|
|
87
|
+
|
|
88
|
+
//// conversion
|
|
89
|
+
|
|
90
|
+
tmp = [0, 0, 0];
|
|
91
|
+
now = performance.now();
|
|
92
|
+
for (let oklch of inputPixelsOKLCH) {
|
|
93
|
+
convert(oklch, OKLCH, target, tmp);
|
|
94
|
+
}
|
|
95
|
+
elapsedOurs = performance.now() - now;
|
|
96
|
+
|
|
97
|
+
now = performance.now();
|
|
98
|
+
for (let oklchColor of culoriInputsOKLCH) {
|
|
99
|
+
p3(oklchColor);
|
|
100
|
+
|
|
101
|
+
// same perf ?
|
|
102
|
+
// p3Converter(oklchColor);
|
|
103
|
+
}
|
|
104
|
+
elapsedCulori = performance.now() - now;
|
|
105
|
+
print("Conversion OKLCH to P3");
|
|
106
|
+
|
|
107
|
+
//// gamut
|
|
108
|
+
|
|
109
|
+
tmp = [0, 0, 0];
|
|
110
|
+
if (fixedCusp && cuspMap) {
|
|
111
|
+
now = performance.now();
|
|
112
|
+
for (let oklch of inputPixelsOKLCH) {
|
|
113
|
+
const cusp = cuspMap[oklch[2]];
|
|
114
|
+
gamutMapOKLCH(oklch, gamut, target, tmp, undefined, cusp);
|
|
115
|
+
}
|
|
116
|
+
elapsedOurs = performance.now() - now;
|
|
117
|
+
} else {
|
|
118
|
+
now = performance.now();
|
|
119
|
+
for (let oklch of inputPixelsOKLCH) {
|
|
120
|
+
gamutMapOKLCH(oklch, gamut, target, tmp);
|
|
121
|
+
}
|
|
122
|
+
elapsedOurs = performance.now() - now;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
now = performance.now();
|
|
126
|
+
for (let oklchColor of culoriInputsOKLCH) {
|
|
127
|
+
toP3Gamut(oklchColor);
|
|
128
|
+
}
|
|
129
|
+
elapsedCulori = performance.now() - now;
|
|
130
|
+
print("Gamut Mapping OKLCH to P3 Gamut");
|
|
131
|
+
|
|
132
|
+
function print(label) {
|
|
133
|
+
console.log("%s --", label);
|
|
134
|
+
console.log("Culori: %s ms", elapsedCulori.toFixed(2));
|
|
135
|
+
console.log("Ours: %s ms", elapsedOurs.toFixed(2));
|
|
136
|
+
if (elapsedCulori > elapsedOurs)
|
|
137
|
+
console.log(
|
|
138
|
+
"Speedup: %sx faster",
|
|
139
|
+
(elapsedCulori / elapsedOurs).toFixed(1)
|
|
140
|
+
);
|
|
141
|
+
else
|
|
142
|
+
console.log(
|
|
143
|
+
"Slowdown: %sx slower",
|
|
144
|
+
(elapsedOurs / elapsedCulori).toFixed(1)
|
|
145
|
+
);
|
|
146
|
+
console.log();
|
|
147
|
+
}
|
|
148
|
+
}
|
package/test/bench-size.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
// To test @texel/color (~3.5 kb)
|
|
2
|
+
import * as colors from "../src/index.js";
|
|
2
3
|
const rgb = colors.convert([0.5, 0.15, 30], colors.OKLCH, colors.sRGB);
|
|
3
4
|
console.log(rgb);
|
|
5
|
+
|
|
6
|
+
// To test colorjs.io (~55.3 kb)
|
|
7
|
+
// import Color from "colorjs.io";
|
|
8
|
+
// console.log(new Color("oklch", [0.5, 0.15, 30]).to("srgb").coords);
|
|
9
|
+
|
|
10
|
+
// To test Culori (~43.2 kb)
|
|
11
|
+
// import { rgb } from "culori";
|
|
12
|
+
// console.log(rgb({ mode: "oklch", l: 0.5, c: 0.15, h: 30 }));
|
|
@@ -0,0 +1,107 @@
|
|
|
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);
|