@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 CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  ![generated](./test/banner.png)
4
4
 
5
- A minimal and modern color library for JavaScript. Mainly useful for real-time applications, generative art, and graphics on the web.
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
- - Optimised for speed: approx 20-125 times faster than [Colorjs.io](https://colorjs.io/) (see [benchmarks](#benchmarks))
9
- - Optimised for low memory and minimal allocations: no arrays or objects are created within conversion and gamut mapping functions
10
- - Optimised 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
- - Optimised for accuracy: [high precision](#accuracy) color space matrices
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. Colorjs is more focused on matching CSS spec, which means it will very likely continue to grow in complexity, and performance will often be marred (for example, `@texel/color` cusp intersection gamut mapping is ~125 times faster than Colorjs and its defaultt CSS based gamut mapping).
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](http://github.com/mattdesl/@texel/color/blob/master/LICENSE.md) for details.
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.1",
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, 0)) {
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
- // special case: if requested targetSpace is base=OKLCH, we can return early.
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
+ }
@@ -1,3 +1,12 @@
1
- import * as colors from "../";
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);