Skip to content

Commit 8246bc4

Browse files
authored
Implement OKLAB color space conversions (#116)
* Add XYZ - OKLAB conversion and vice-versa * Add OKLAB - OKLCH conversion and vice-versa * Simplify XYZ - OKLAB conversion implementation * Simplify OKLAB - OKLCH conversion implementation * Factorize sRGB non-linear transform functions * Add RGB - OKLAB conversion and vise-versa * Small aestethic changes * Update bounds
1 parent f53dad2 commit 8246bc4

File tree

3 files changed

+143
-22
lines changed

3 files changed

+143
-22
lines changed

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,41 @@ h | 360
8989
w | 100
9090
b | 100
9191

92+
### xyz
93+
channel | full-scale value
94+
---|---
95+
x | 94
96+
y | 99
97+
z | 108
98+
99+
### lab
100+
channel | full-scale value
101+
---|---
102+
l | 100
103+
a | -86, 98
104+
b | -108, 94
105+
106+
### lch
107+
channel | full-scale value
108+
---|---
109+
l | 100
110+
c | 133
111+
h | 360
112+
113+
### oklab
114+
channel | full-scale value
115+
---|---
116+
l | 100
117+
a | -23, 28
118+
b | -31, 20
119+
120+
### oklch
121+
channel | full-scale value
122+
---|---
123+
l | 100
124+
c | 32
125+
h | 360
126+
92127
### cmyk
93128
channel | full-scale value
94129
---|---

conversions.js

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ const convert = {
1919
cmyk: {channels: 4, labels: 'cmyk'},
2020
xyz: {channels: 3, labels: 'xyz'},
2121
lab: {channels: 3, labels: 'lab'},
22+
oklab: {channels: 3, labels: ['okl', 'oka', 'okb']},
2223
lch: {channels: 3, labels: 'lch'},
24+
oklch: {channels: 3, labels: ['okl', 'okc', 'okh']},
2325
hex: {channels: 1, labels: ['hex']},
2426
keyword: {channels: 1, labels: ['keyword']},
2527
ansi16: {channels: 1, labels: ['ansi16']},
@@ -34,6 +36,18 @@ export default convert;
3436
// LAB f(t) constant
3537
const LAB_FT = (6 / 29) ** 3;
3638

39+
// SRGB non-linear transform functions
40+
function srgbNonlinearTransform(c) {
41+
const cc = c > 0.003_130_8
42+
? ((1.055 * (c ** (1 / 2.4))) - 0.055)
43+
: c * 12.92;
44+
return Math.min(Math.max(0, cc), 1);
45+
}
46+
47+
function srgbNonlinearTransformInv(c) {
48+
return c > 0.040_45 ? (((c + 0.055) / 1.055) ** 2.4) : (c / 12.92);
49+
}
50+
3751
// Hide .channels and .labels properties
3852
for (const model of Object.keys(convert)) {
3953
if (!('channels' in convert[model])) {
@@ -183,6 +197,23 @@ convert.rgb.hwb = function (rgb) {
183197
return [h, w * 100, b * 100];
184198
};
185199

200+
convert.rgb.oklab = function (rgb) {
201+
// Assume sRGB
202+
const r = srgbNonlinearTransformInv(rgb[0] / 255);
203+
const g = srgbNonlinearTransformInv(rgb[1] / 255);
204+
const b = srgbNonlinearTransformInv(rgb[2] / 255);
205+
206+
const lp = Math.cbrt(0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b);
207+
const mp = Math.cbrt(0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b);
208+
const sp = Math.cbrt(0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b);
209+
210+
const l = 0.210_454_255_3 * lp + 0.793_617_785 * mp - 0.004_072_046_8 * sp;
211+
const aa = 1.977_998_495_1 * lp - 2.428_592_205 * mp + 0.450_593_709_9 * sp;
212+
const bb = 0.025_904_037_1 * lp + 0.782_771_766_2 * mp - 0.808_675_766 * sp;
213+
214+
return [l * 100, aa * 100, bb * 100];
215+
};
216+
186217
convert.rgb.cmyk = function (rgb) {
187218
const r = rgb[0] / 255;
188219
const g = rgb[1] / 255;
@@ -237,14 +268,10 @@ convert.keyword.rgb = function (keyword) {
237268
};
238269

239270
convert.rgb.xyz = function (rgb) {
240-
let r = rgb[0] / 255;
241-
let g = rgb[1] / 255;
242-
let b = rgb[2] / 255;
243-
244271
// Assume sRGB
245-
r = r > 0.040_45 ? (((r + 0.055) / 1.055) ** 2.4) : (r / 12.92);
246-
g = g > 0.040_45 ? (((g + 0.055) / 1.055) ** 2.4) : (g / 12.92);
247-
b = b > 0.040_45 ? (((b + 0.055) / 1.055) ** 2.4) : (b / 12.92);
272+
const r = srgbNonlinearTransformInv(rgb[0] / 255);
273+
const g = srgbNonlinearTransformInv(rgb[1] / 255);
274+
const b = srgbNonlinearTransformInv(rgb[2] / 255);
248275

249276
const x = (r * 0.412_456_4) + (g * 0.357_576_1) + (b * 0.180_437_5);
250277
const y = (r * 0.212_672_9) + (g * 0.715_152_2) + (b * 0.072_175);
@@ -471,21 +498,9 @@ convert.xyz.rgb = function (xyz) {
471498
b = (x * 0.055_643_4) + (y * -0.204_025_9) + (z * 1.057_225_2);
472499

473500
// Assume sRGB
474-
r = r > 0.003_130_8
475-
? ((1.055 * (r ** (1 / 2.4))) - 0.055)
476-
: r * 12.92;
477-
478-
g = g > 0.003_130_8
479-
? ((1.055 * (g ** (1 / 2.4))) - 0.055)
480-
: g * 12.92;
481-
482-
b = b > 0.003_130_8
483-
? ((1.055 * (b ** (1 / 2.4))) - 0.055)
484-
: b * 12.92;
485-
486-
r = Math.min(Math.max(0, r), 1);
487-
g = Math.min(Math.max(0, g), 1);
488-
b = Math.min(Math.max(0, b), 1);
501+
r = srgbNonlinearTransform(r);
502+
g = srgbNonlinearTransform(g);
503+
b = srgbNonlinearTransform(b);
489504

490505
return [r * 255, g * 255, b * 255];
491506
};
@@ -510,6 +525,63 @@ convert.xyz.lab = function (xyz) {
510525
return [l, a, b];
511526
};
512527

528+
convert.xyz.oklab = function (xyz) {
529+
const x = xyz[0] / 100;
530+
const y = xyz[1] / 100;
531+
const z = xyz[2] / 100;
532+
533+
const lp = Math.cbrt(0.818_933_010_1 * x + 0.361_866_742_4 * y - 0.128_859_713_7 * z);
534+
const mp = Math.cbrt(0.032_984_543_6 * x + 0.929_311_871_5 * y + 0.036_145_638_7 * z);
535+
const sp = Math.cbrt(0.048_200_301_8 * x + 0.264_366_269_1 * y + 0.633_851_707 * z);
536+
537+
const l = 0.210_454_255_3 * lp + 0.793_617_785 * mp - 0.004_072_046_8 * sp;
538+
const a = 1.977_998_495_1 * lp - 2.428_592_205 * mp + 0.450_593_709_9 * sp;
539+
const b = 0.025_904_037_1 * lp + 0.782_771_766_2 * mp - 0.808_675_766 * sp;
540+
541+
return [l * 100, a * 100, b * 100];
542+
};
543+
544+
convert.oklab.oklch = function (oklab) {
545+
return convert.lab.lch(oklab);
546+
};
547+
548+
convert.oklab.xyz = function (oklab) {
549+
const ll = oklab[0] / 100;
550+
const a = oklab[1] / 100;
551+
const b = oklab[2] / 100;
552+
553+
const l = (0.999_999_998 * ll + 0.396_337_792 * a + 0.215_803_758 * b) ** 3;
554+
const m = (1.000_000_008 * ll - 0.105_561_342 * a - 0.063_854_175 * b) ** 3;
555+
const s = (1.000_000_055 * ll - 0.089_484_182 * a - 1.291_485_538 * b) ** 3;
556+
557+
const x = 1.227_013_851 * l - 0.557_799_98 * m + 0.281_256_149 * s;
558+
const y = -0.040_580_178 * l + 1.112_256_87 * m - 0.071_676_679 * s;
559+
const z = -0.076_381_285 * l - 0.421_481_978 * m + 1.586_163_22 * s;
560+
561+
return [x * 100, y * 100, z * 100];
562+
};
563+
564+
convert.oklab.rgb = function (oklab) {
565+
const ll = oklab[0] / 100;
566+
const aa = oklab[1] / 100;
567+
const bb = oklab[2] / 100;
568+
569+
const l = (ll + 0.396_337_777_4 * aa + 0.215_803_757_3 * bb) ** 3;
570+
const m = (ll - 0.105_561_345_8 * aa - 0.063_854_172_8 * bb) ** 3;
571+
const s = (ll - 0.089_484_177_5 * aa - 1.291_485_548 * bb) ** 3;
572+
573+
// Assume sRGB
574+
const r = srgbNonlinearTransform(4.076_741_662_1 * l - 3.307_711_591_3 * m + 0.230_969_929_2 * s);
575+
const g = srgbNonlinearTransform(-1.268_438_004_6 * l + 2.609_757_401_1 * m - 0.341_319_396_5 * s);
576+
const b = srgbNonlinearTransform(-0.004_196_086_3 * l - 0.703_418_614_7 * m + 1.707_614_701 * s);
577+
578+
return [r * 255, g * 255, b * 255];
579+
};
580+
581+
convert.oklch.oklab = function (oklch) {
582+
return convert.lch.lab(oklch);
583+
};
584+
513585
convert.lab.xyz = function (lab) {
514586
const l = lab[0];
515587
const a = lab[1];

test/basic.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ assert.deepStrictEqual(convert.rgb.cmyk([0, 0, 0, 1]), [0, 0, 0, 100]);
5050
assert.deepStrictEqual(convert.rgb.keyword([255, 228, 196]), 'bisque');
5151
assert.deepStrictEqual(convert.rgb.xyz([92, 191, 84]), [25, 40, 15]);
5252
assert.deepStrictEqual(convert.rgb.lab([92, 191, 84]), [70, -50, 45]);
53+
assert.deepStrictEqual(convert.rgb.oklab([153, 102, 255]), [64, 9, -20]);
5354
assert.deepStrictEqual(convert.rgb.lch([92, 191, 84]), [70, 67, 138]);
55+
assert.deepStrictEqual(convert.rgb.oklch([153, 102, 255]), [64, 22, 295]);
5456
assert.deepStrictEqual(convert.rgb.ansi16([92, 191, 84]), 32);
5557
assert.deepStrictEqual(convert.rgb.ansi256([92, 191, 84]), 114);
5658
assert.deepStrictEqual(convert.rgb.hex([92, 191, 84]), '5CBF54');
@@ -101,16 +103,28 @@ assert.deepStrictEqual(convert.keyword.hex('blue'), '0000FF');
101103
assert.deepStrictEqual(convert.xyz.rgb([25, 40, 15]), [97, 190, 85]);
102104
assert.deepStrictEqual(convert.xyz.rgb([50, 100, 100]), [0, 255, 241]);
103105
assert.deepStrictEqual(convert.xyz.lab([25, 40, 15]), [69, -48, 44]);
106+
assert.deepStrictEqual(convert.xyz.oklab([95, 100, 108.9]), [100, -0, -0]);
107+
assert.deepStrictEqual(convert.xyz.oklab([100, 0, 0]), [45, 124, -2]);
108+
assert.deepStrictEqual(convert.xyz.oklab([0, 100, 0]), [92, -67, 26]);
109+
assert.deepStrictEqual(convert.xyz.oklab([0, 0, 100]), [15, -141, -45]);
104110
assert.deepStrictEqual(convert.xyz.lch([25, 40, 15]), [69, 65, 137]);
105111

106112
assert.deepStrictEqual(convert.lab.xyz([69, -48, 44]), [25, 39, 15]);
107113
assert.deepStrictEqual(convert.lab.rgb([75, 20, -30]), [194, 175, 240]);
108114
assert.deepStrictEqual(convert.lab.lch([69, -48, 44]), [69, 65, 137]);
109115

116+
assert.deepStrictEqual(convert.oklab.xyz([100, 0, 0]), [95, 100, 109]);
117+
assert.deepStrictEqual(convert.oklab.xyz([45, 123.6, -1.9]), [100, -0, -0]);
118+
assert.deepStrictEqual(convert.oklab.xyz([92.2, -67.1, 26.3]), [0, 100, 0]);
119+
assert.deepStrictEqual(convert.oklab.xyz([15.3, -141.5, -44.9]), [0, 0, 100]);
120+
assert.deepStrictEqual(convert.oklab.rgb([64, 9, -20]), [152, 102, 255]);
121+
110122
assert.deepStrictEqual(convert.lch.lab([69, 65, 137]), [69, -48, 44]);
111123
assert.deepStrictEqual(convert.lch.xyz([69, 65, 137]), [25, 39, 15]);
112124
assert.deepStrictEqual(convert.lch.rgb([69, 65, 137]), [98, 188, 83]);
113125

126+
assert.deepStrictEqual(convert.oklch.rgb([64, 22, 295]), [154, 101, 255]);
127+
114128
assert.deepStrictEqual(convert.ansi16.rgb(103), [255, 255, 0]);
115129
assert.deepStrictEqual(convert.ansi256.rgb(175), [204, 102, 153]);
116130

0 commit comments

Comments
 (0)