@texel/color 1.0.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@texel/color",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "a minimal and modern color library",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
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
@@ -19,6 +19,8 @@ import {
19
19
  import { p3, toGamut, oklch, okhsl, converter } from "culori";
20
20
 
21
21
  const N = 256 * 256;
22
+ const gamut = DisplayP3Gamut;
23
+ const target = gamut.space;
22
24
 
23
25
  // get N OKLCH in-gamut pixels by sampling uniformly from OKHSL cube
24
26
  const oklchPixelsInGamut = Array(N)
@@ -27,8 +29,8 @@ const oklchPixelsInGamut = Array(N)
27
29
  const t = i / (lst.length - 1);
28
30
  // note if we use the standard OKHSL space, it is bound to sRGB gamut...
29
31
  // so instead we use the OKHSLToOKLab function and pass P3 gamut
30
- const okhsl = [t, t, t * 360];
31
- const oklab = OKHSLToOKLab(okhsl, DisplayP3Gamut);
32
+ const okhsl = [t * 360, t, t];
33
+ const oklab = OKHSLToOKLab(okhsl, gamut);
32
34
  return convert(oklab, OKLab, OKLCH);
33
35
  });
34
36
 
@@ -44,12 +46,34 @@ const toP3Gamut = toGamut("p3", "oklch");
44
46
  // same perf as p3() it seems
45
47
  // const p3Converter = converter("p3");
46
48
 
47
- test(oklchPixelsInGamut, "Random Samling in P3 Gamut");
48
- test(oklchPixelsRandom, "Random Samling in OKLab L Planes");
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
+ }
49
70
 
50
- function test(inputPixelsOKLCH, label) {
51
- console.log("Testing with input type: %s", label);
52
- const culoriInputsOKLCH = oklchPixelsRandom.map(([l, c, h]) => {
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]) => {
53
77
  return {
54
78
  mode: "oklch",
55
79
  l,
@@ -66,7 +90,7 @@ function test(inputPixelsOKLCH, label) {
66
90
  tmp = [0, 0, 0];
67
91
  now = performance.now();
68
92
  for (let oklch of inputPixelsOKLCH) {
69
- convert(oklch, OKLCH, DisplayP3, tmp);
93
+ convert(oklch, OKLCH, target, tmp);
70
94
  }
71
95
  elapsedOurs = performance.now() - now;
72
96
 
@@ -74,7 +98,7 @@ function test(inputPixelsOKLCH, label) {
74
98
  for (let oklchColor of culoriInputsOKLCH) {
75
99
  p3(oklchColor);
76
100
 
77
- // same perf
101
+ // same perf ?
78
102
  // p3Converter(oklchColor);
79
103
  }
80
104
  elapsedCulori = performance.now() - now;
@@ -83,11 +107,20 @@ function test(inputPixelsOKLCH, label) {
83
107
  //// gamut
84
108
 
85
109
  tmp = [0, 0, 0];
86
- now = performance.now();
87
- for (let oklch of inputPixelsOKLCH) {
88
- gamutMapOKLCH(oklch, DisplayP3Gamut, DisplayP3, tmp);
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;
89
123
  }
90
- elapsedOurs = performance.now() - now;
91
124
 
92
125
  now = performance.now();
93
126
  for (let oklchColor of culoriInputsOKLCH) {
@@ -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);