@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 +1 -1
- package/src/gamut.js +8 -12
- package/test/bench-culori.js +46 -13
- package/test/test-gamut-epsilon.js +107 -0
package/package.json
CHANGED
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
|
package/test/bench-culori.js
CHANGED
|
@@ -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
|
|
31
|
-
const oklab = OKHSLToOKLab(okhsl,
|
|
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
|
|
48
|
-
test(oklchPixelsRandom, "Random
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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,
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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);
|