hadalized 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,1903 @@
1
+ """
2
+ https://bottosson.github.io/posts/oklab/ for general information explaining
3
+ the oklab space. Exerpts below.
4
+
5
+ A color in Oklab is represented with three coordinates, similar to how CIELAB
6
+ works, but with better perceptual properties. Oklab uses a D65 whitepoint,
7
+ since this is what sRGB and other common color spaces use.
8
+ The three coordinates are:
9
+
10
+ L – perceived lightness
11
+ a – how green/red the color is
12
+ b – how blue/yellow the color is
13
+
14
+ For many operations, Lab-coordinates can be used directly, but they can also
15
+ be transformed into polar form, with the coordinates lightness, chroma and hue.
16
+
17
+ LAB <-> LCh
18
+
19
+ C = sqrt(a^2 + b^2)
20
+
21
+ h∘ = atan2(b, a)
22
+
23
+ From C, and h∘ the coordinates a, b can be computed like this:
24
+ a = C cos(h∘)
25
+ b = C sin(h∘)
26
+
27
+ Given a vector v in XYZ coordinates, with M_1 and M_2 below, the XYZ -> Lab
28
+ transformation is
29
+ M_2((M_1v)^{1/3})
30
+ That is first the XYZ coordinates are converted to "approximate cone responses"
31
+ (lms), then the cubed root of each vector element taken, then the Lab
32
+ transform via M_2.
33
+
34
+ """
35
+ import math
36
+ import sys
37
+ from pprint import pprint
38
+ from typing import Callable, ClassVar, NamedTuple, Self
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Typedefs
43
+ # ---------------------------------------------------------------------------
44
+ type ScalarFunc = Callable[[float], float]
45
+ type Vector3dPlus = tuple[float, float, float, *tuple[float | None, ...]]
46
+ """A tuple with at least 3 floats."""
47
+
48
+ type Vector2d = tuple[float, float]
49
+ type Vector3d = tuple[float, float, float]
50
+ type VectorNd = tuple[float, ...]
51
+
52
+ type Matrix3d = tuple[
53
+ Vector3dPlus,
54
+ Vector3dPlus,
55
+ Vector3dPlus,
56
+ ]
57
+
58
+
59
+ type Matrix3x2 = tuple[
60
+ Vector2d,
61
+ Vector2d,
62
+ Vector2d,
63
+ ]
64
+ """Linar transformations from R^2 -> R^3"""
65
+
66
+ type Matrix3x3 = tuple[
67
+ Vector3d,
68
+ Vector3d,
69
+ Vector3d,
70
+ ]
71
+ """Linar transformations from R^3 -> R^3"""
72
+ # ---------------------------------------------------------------------------
73
+ # basic helper utils
74
+ # ---------------------------------------------------------------------------
75
+
76
+ def _hex(val: int) -> str:
77
+ return hex(val)[2:].lower()
78
+
79
+
80
+ def _iround(val: float) -> int:
81
+ """Integer rounding."""
82
+ ret = int(val)
83
+ if (val - ret) >= 0.5:
84
+ ret += 1
85
+ return ret
86
+
87
+
88
+ def to_byte(val: float) -> int:
89
+ """Map the unit interval to the nearest 8 bit integer."""
90
+ val = 255*max(val, 0)
91
+ ret = int(val)
92
+ if (val - ret) >= 0.5:
93
+ ret += 1
94
+ return min(255, ret)
95
+
96
+
97
+
98
+ def _ceil(f, num_digits=0):
99
+ f = math.ceil(f * 10**num_digits) / 10.0**num_digits
100
+ return float(format(f, "." + str(num_digits) + "f"))
101
+
102
+
103
+ def _floor(f, num_digits=0):
104
+ f = math.floor(f * 10**num_digits) / 10.0**num_digits
105
+ return float(format(f, "." + str(num_digits) + "f"))
106
+
107
+
108
+
109
+ def dot(v: Vector3dPlus, w: Vector3dPlus)-> float:
110
+ """Dot product of two vectors in R^3."""
111
+ return v[0]*w[0] + v[1]*w[1] + v[2]*w[2]
112
+
113
+
114
+ def square(val: float) -> float:
115
+ """Squarethe input."""
116
+ return val**2
117
+
118
+
119
+ def cube(val: float) -> float:
120
+ """Cube the input."""
121
+ return math.pow(val, 3)
122
+
123
+ def square3d(val: Vector3d) -> Vector3d:
124
+ """Square each coordinate in a vector in R^3."""
125
+ return (val[0]**2, val[1]**2, val[2]**2)
126
+
127
+ def cube3d(val: Vector3d) -> Vector3d:
128
+ """Cube each coordinate in a vector in R^3."""
129
+ return (val[0]**3, val[1]**3, val[2]**3)
130
+
131
+
132
+ def hue_to_ab(hue: float) -> tuple[float, float]:
133
+ """Map cylindrical hue oklch value to an OKLAB (a, b) coordinate
134
+ on the unit circle."""
135
+ return (
136
+ math.cos(math.radians(hue)),
137
+ math.sin(math.radians(hue)),
138
+ )
139
+
140
+
141
+ def dotprod(v: VectorNd, w: VectorNd) -> float:
142
+ """Take the dot produce of two vectors in R^n."""
143
+ match len(v):
144
+ case 3:
145
+ out = v[0]*w[0] + v[1]*w[1] + v[2]*w[2]
146
+ case 2:
147
+ out = v[0]*w[0] + v[1]*w[1]
148
+ case _:
149
+ out = sum(map(lambda x: x[0]*x[1], zip(v, w)))
150
+ return out
151
+
152
+
153
+ def dot2d(v: Vector2d, w: Vector2d) -> float:
154
+ """Dot product in R^3"""
155
+ return v[0]*w[0] + v[1]*w[1]
156
+
157
+
158
+ def dot3d(v: Vector3d, w: Vector3d) -> float:
159
+ """Dot product in R^3"""
160
+ return v[0]*w[0] + v[1]*w[1] + v[2]*w[2]
161
+
162
+
163
+ def linear_trans_2d_to_3d(M: Matrix3x2, v: Vector2d) -> Vector3d:
164
+ """Linear transformation from R^2 -> R^3."""
165
+ return (
166
+ dot2d(M[0], v),
167
+ dot2d(M[1], v),
168
+ dot2d(M[2], v),
169
+ )
170
+
171
+
172
+ def linear_trans_3d_to_3d(M: Matrix3x3, v: Vector3d) -> Vector3d:
173
+ """Linear transformation from R^3 -> R^3."""
174
+ return (
175
+ dot3d(M[0], v),
176
+ dot3d(M[1], v),
177
+ dot3d(M[2], v),
178
+ )
179
+
180
+
181
+ def vmap(func: ScalarFunc, vector: VectorNd) -> VectorNd:
182
+ """Map a scalar function across each element of the vector.
183
+ """
184
+ return tuple(map(func, vector))
185
+
186
+ def vmul3d(v: Vector3d, w: Vector3d) -> Vector3d:
187
+ return (
188
+ v[0]*w[0],
189
+ v[1]*w[1],
190
+ v[2]*w[2],
191
+ )
192
+
193
+
194
+ def vadd3d(v: Vector3d, w: Vector3d) -> Vector3d:
195
+ return (
196
+ v[0]+w[0],
197
+ v[1]+w[1],
198
+ v[2]+w[2],
199
+ )
200
+
201
+
202
+ def vmap2d(func: ScalarFunc, vector: Vector2d) -> Vector2d:
203
+ """Map a scalar function across each element of the vector.
204
+ """
205
+ return (
206
+ func(vector[0]),
207
+ func(vector[1]),
208
+ )
209
+
210
+ def vmap3d(func: ScalarFunc, vector: Vector3d) -> Vector3d:
211
+ """Map a scalar function across each element of the vector.
212
+ """
213
+ return (
214
+ func(vector[0]),
215
+ func(vector[1]),
216
+ func(vector[2]),
217
+ )
218
+
219
+ def smul3d(scalar: float, vector: Vector3d) -> Vector3d:
220
+ """Multiple a scalar value to a vector in R^3."""
221
+ return (scalar*vector[0], scalar*vector[1], scalar*vector[2])
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # functional rewrite
226
+ # ---------------------------------------------------------------------------
227
+
228
+ # Usually when percentage values have a numeric equivalent in CSS, 100% is
229
+ # equal to the number 1. This is not the case for all of the oklch
230
+ # component values. Here, 100% is equal to 0.4 for the C value.
231
+
232
+ class Vector(NamedTuple):
233
+ """Base class for color vectors together with an optional alpha channel."""
234
+ x: float
235
+ y: float
236
+ z: float
237
+ alpha: float | None = None
238
+
239
+ @classmethod
240
+ def cast(cls, val: "Vector") -> Self:
241
+ """Typecast between subclasses."""
242
+ return cls(val.x, val.y, val.z, val.alpha)
243
+
244
+ def map(self, func: ScalarFunc) -> Self:
245
+ """Map a scalar function across each element of the vector.
246
+ The alpha channel stays the same.
247
+ """
248
+ return self.__class__(
249
+ func(self.x),
250
+ func(self.y),
251
+ func(self.z),
252
+ self.alpha,
253
+ )
254
+
255
+ # def dot(self, vec: Vector3d) -> Self:
256
+ # """Take the dot product of two vectors. The alpha channel remains
257
+ # unchanged.
258
+ # """
259
+ # return self.__class__(
260
+ # self[0]*vec[0],
261
+ # self[1]*vec[1],
262
+ # self[2]*vec[2],
263
+ # self.alpha
264
+ # )
265
+ #
266
+
267
+ def T(self, M: Matrix3d) -> Self:
268
+ """Apply a linear transformation M(v) where v is the instance.
269
+ Equivalent to vM^T when v is viewed as a row vector.
270
+
271
+ The alpha channel stays the same.
272
+ """
273
+ return self.__class__(
274
+ dot(M[0], self),
275
+ dot(M[1], self),
276
+ dot(M[2], self),
277
+ self.alpha,
278
+ )
279
+
280
+
281
+
282
+
283
+
284
+
285
+ def _lrgb_to_srgb(val: float) -> float:
286
+ """Map linear RGB to standard RGB. The inverse of the SRGB transfer."""
287
+ if val >= 0.0031308:
288
+ ret = (1.055 * math.pow(val, 5 / 12)) - 0.055
289
+ else:
290
+ ret = 12.92 * val
291
+ return ret
292
+
293
+
294
+ def _srgb_to_lrgb(val: float):
295
+ """Map a Standard RGB channel to linear RGB.
296
+
297
+ Also known as the "transfer" or "gamma" function.
298
+ Cf: https://en.wikipedia.org/wiki/SRGB
299
+
300
+ Args:
301
+ val: A scalar value representing a color channel in the unit interval.
302
+
303
+ Returns:
304
+ A linear intensity.
305
+
306
+ """
307
+ if val >= 0.04045:
308
+ ret = math.pow((val + 0.055) / 1.055, 2.4)
309
+ else:
310
+ ret = val / 12.92
311
+ return ret
312
+
313
+
314
+ class Oklab(Vector):
315
+
316
+ @property
317
+ def lightness(self) -> float:
318
+ """Lightness."""
319
+ return self.x
320
+
321
+ @property
322
+ def a(self) -> float:
323
+ return self.y
324
+
325
+ @property
326
+ def b(self) -> float:
327
+ return self.z
328
+
329
+
330
+ class Oklch(Vector):
331
+
332
+ @property
333
+ def lightness(self) -> float:
334
+ """Lightness."""
335
+ return self.x
336
+
337
+ @property
338
+ def chroma(self) -> float:
339
+ """Chroma. Similar to saturation. 0.4 represents 100%."""
340
+ return self.y
341
+
342
+ @property
343
+ def hue(self) -> float:
344
+ """Hue as degree measurement in [0, 360.]"""
345
+ return self.z
346
+
347
+ def normalized_ab(self) -> tuple[float, float]:
348
+ """Get normalized a, b values that are provided in the conversion
349
+ to OKLab or in gamut clipping functions.
350
+ """
351
+ a = math.cos(math.radians(self.hue))
352
+ b = math.sin(math.radians(self.hue))
353
+ return (a, b)
354
+
355
+ @classmethod
356
+ def parse(cls, val: str) -> Self:
357
+ val, _, alpha = val.strip("oklch(").strip(")").partition("/")
358
+ comps: list[str] = val.strip().split(" ")
359
+ alpha = alpha.strip()
360
+ l_str = comps[0]
361
+ if l_str .endswith("%"):
362
+ lightness = float(l_str[:-1]) / 100
363
+ else:
364
+ lightness = float(l_str)
365
+ return cls(
366
+ lightness,
367
+ float(comps[1]),
368
+ float(comps[2]),
369
+ float(alpha) if alpha else None
370
+ )
371
+
372
+ @property
373
+ def hex(self) -> str:
374
+ return oklch_to_hex(self)
375
+
376
+ def __str__(self) -> str:
377
+ if self.alpha is not None:
378
+ post = f" / {self.alpha}"
379
+ else:
380
+ post = ""
381
+ return f"oklch({self.lightness} {self.chroma} {self.hue}{post})"
382
+
383
+ @property
384
+ def css(self) -> str:
385
+ return str(self)
386
+
387
+
388
+
389
+ class Rgb(Vector):
390
+
391
+ @property
392
+ def red(self) -> float:
393
+ return self.x
394
+
395
+ @property
396
+ def green(self) -> float:
397
+ """Chroma. Similar to saturation. 0.4 represents 100%."""
398
+ return self.y
399
+
400
+ @property
401
+ def blue(self) -> float:
402
+ """Hue as degree measurement in [0, 360.]"""
403
+ return self.z
404
+
405
+ @property
406
+ def hex(self) -> str:
407
+ return rgb_to_hex(self)
408
+
409
+
410
+
411
+
412
+ class M:
413
+ """Container for linear transformations used to convert from OK to RGB."""
414
+
415
+ lab_to_lms: ClassVar[Matrix3d] = (
416
+ (1, +0.3963377774, +0.2158037573),
417
+ (1, -0.1055613458, -0.0638541728),
418
+ (1, -0.0894841775, -1.2914855480),
419
+ )
420
+ """M_2^-1. First transformation applied to OKLAB coordinates."""
421
+
422
+ lms_to_rgb: ClassVar[Matrix3d] = (
423
+ (4.0767416621, -3.3077115913, 0.2309699292),
424
+ (-1.2684380046, 2.6097574011, -0.3413193965),
425
+ (-0.0041960863, -0.7034186147, 1.7076147010),
426
+ )
427
+ """M_{xyz_to_srgb}M_1^{-1}
428
+ Second transformation applied to OKLAB coordinates."""
429
+
430
+ rgb_to_lms: ClassVar[Matrix3d] = (
431
+ (+0.4122214708, 0.5363325363, 0.0514459929),
432
+ (+0.2119034982, 0.6806995451, 0.1073969566),
433
+ (+0.0883024619, 0.2817188376, 0.6299787005),
434
+ )
435
+ """Must be (M_{xyz_to_rgb}M_1^{-1})^{-1} = M_1M_{xyz_to_rgb}^{-1}
436
+ First transformation applied to RGB coordinates."""
437
+
438
+ lms_to_lab: ClassVar[Matrix3d] = (
439
+ (0.2104542553, 0.7936177850, -0.0040720468),
440
+ (1.9779984951, -2.4285922050, 0.4505937099),
441
+ (0.0259040371, 0.7827717662, -0.8086757660),
442
+ )
443
+ """M_2 in original paper. Second transformation applied to RGB coordinates."""
444
+
445
+ xyz_to_lms: Matrix3d = (
446
+ (0.8189330101, 0.3618667424, -0.1288597137),
447
+ (0.0329845436, 0.9293118715, +0.0361456387),
448
+ (0.0482003018, 0.2643662691, +0.6338517070),
449
+ )
450
+ """M_1 in original oklab definition."""
451
+
452
+ lms_to_xyz: Matrix3d = (
453
+ (1.22701, -0.5578, 0.281256),
454
+ (-0.0405802, 1.11226, -0.0716767),
455
+ (-0.0763813, -0.421482, 1.58616),
456
+ )
457
+ """M_1^{-1}, the inverse of M_1. Precision issue?"""
458
+
459
+ xyz_to_rgb: Matrix3d = (
460
+ (3.24061, -1.5376, -0.498486),
461
+ (-0.969136, 1.87604, 0.041436),
462
+ (0.0556693, -0.203778, 1.05749),
463
+ )
464
+
465
+ rgb_to_xyz: Matrix3d = (
466
+ (0.412437, 0.357629, 0.180404),
467
+ (0.212634, 0.715156, 0.0722104),
468
+ (0.0192626, 0.118984, 0.950053),
469
+ )
470
+ """Inverse of M_{xyz_to_rgb}. Precision issue?"""
471
+
472
+
473
+
474
+
475
+
476
+ def oklch_to_oklab(val: Oklch) -> Oklab:
477
+ (a, b), c = (val.normalized_ab(), val.chroma)
478
+ return Oklab(val.lightness, a * c, b * c, val.alpha)
479
+
480
+
481
+ def oklab_to_linear_rgb(val: Vector) -> Vector:
482
+ return Rgb.cast(
483
+ val.T(M.lab_to_lms)
484
+ .map(cube)
485
+ .T(M.lms_to_rgb)
486
+ # .map(_lrgb_to_srgb)
487
+ # .map(to_byte)
488
+ )
489
+
490
+ def oklab_to_rgb(val: Vector) -> Rgb:
491
+ return Rgb.cast(
492
+ val.T(M.lab_to_lms)
493
+ .map(cube)
494
+ .T(M.lms_to_rgb)
495
+ .map(_lrgb_to_srgb)
496
+ .map(to_byte)
497
+ )
498
+
499
+ # NOTE: original change
500
+ # _l, a, b = val
501
+ # l3 = math.pow(_l + 0.3963377774 * a + 0.2158037573 * b, 3)
502
+ # m3 = math.pow(_l - 0.1055613458 * a - 0.0638541728 * b, 3)
503
+ # s3 = math.pow(_l - 0.0894841775 * a - 1.2914855480 * b, 3)
504
+ #
505
+ #
506
+ # tr = _srgb_transfer_int
507
+ #
508
+ # return Rgb(
509
+ # red=_srgb_transfer_int(
510
+ # +4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3
511
+ # ),
512
+ # green=_srgb_transfer_int(
513
+ # -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3
514
+ # ),
515
+ # blue=_srgb_transfer_int(
516
+ # -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3
517
+ # ),
518
+ # )
519
+
520
+
521
+ def oklab_to_oklch(val: Vector) -> Oklch:
522
+ lightness, a, b, alpha = val
523
+ return Oklch(
524
+ lightness,
525
+ math.sqrt(a**2 + b**2),
526
+ math.degrees(math.atan2(b, a)) % 360,
527
+ alpha,
528
+ )
529
+
530
+
531
+
532
+ def rgb_to_oklab(val: Vector) -> Oklab:
533
+ """Map from RGB to OKLAB."""
534
+ return Oklab.cast(
535
+ val.map(lambda x: _srgb_to_lrgb(x / 255))
536
+ .T(M.rgb_to_lms)
537
+ .map(math.cbrt)
538
+ .T(M.lms_to_lab)
539
+ )
540
+ # Original
541
+ # red = _srgb_transfer_inverse(val.red / 255)
542
+ # green = _srgb_transfer_inverse(val.green / 255)
543
+ # blue = _srgb_transfer_inverse(val.blue / 255)
544
+ # exp = 1 / 3
545
+ #
546
+ # l_ = math.pow(
547
+ # 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue,
548
+ # exp,
549
+ # )
550
+ # m_ = math.pow(
551
+ # 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue,
552
+ # exp,
553
+ # )
554
+ # s_ = math.pow(
555
+ # 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue,
556
+ # exp,
557
+ # )
558
+ #
559
+ # return Oklab(
560
+ # 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
561
+ # 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
562
+ # 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
563
+ # )
564
+
565
+
566
+ def rgb_to_hex(val: Vector) -> str:
567
+ r, g, b, alpha = val
568
+ code = f"#{r:02x}{g:02x}{b:02x}"
569
+ if alpha is not None:
570
+ code += f"{to_byte(alpha):02x}"
571
+ return code
572
+
573
+
574
+
575
+ def oklch_to_rgb(val: Oklch) -> Rgb:
576
+ return oklab_to_rgb(oklch_to_oklab(val))
577
+
578
+ def oklch_to_hex(val: Oklch) -> str:
579
+ return rgb_to_hex(oklab_to_rgb(oklch_to_oklab(val)))
580
+
581
+
582
+ def hex_to_oklch(code: str) -> Oklch:
583
+ """Map"""
584
+ rgb = parse_hex(code)
585
+ return oklab_to_oklch(rgb_to_oklab(rgb))
586
+
587
+
588
+ def hex_to_oklch_css(code: str) -> str:
589
+ """Map"""
590
+ return oklab_to_oklch(rgb_to_oklab(parse_hex(code))).css
591
+
592
+ def parse_hex(code: str) -> Rgb:
593
+ if code.startswith("#"):
594
+ code = code[1:]
595
+ if code.startswith("0x"):
596
+ code = code[2:]
597
+ return Rgb(
598
+ int(code[0:2], 16),
599
+ int(code[2:4], 16),
600
+ int(code[4:6], 16),
601
+ alpha=int(code[-2:], 16) / 255 if len(code) == 8 else None,
602
+ )
603
+
604
+
605
+ def run_test():
606
+ """Test case consists of an oklch (user defined) and a hex value computed.
607
+ """
608
+ tests = [
609
+ {
610
+ "desc": "chroma massively out of bounds",
611
+ "oklch": "oklch(0.6 0.50 25)",
612
+ "hex": "#ef0028",
613
+ "oklch_in_srgb": "oklch(60% 0.2433 25)",
614
+ "hex_in_srgb": "#ef0028",
615
+ "hex_to_oklch_color_picker": "oklch(60% 0.2432 25)",
616
+ "hex_in_srgb_to_oklch_color_picker": "oklch(60% 0.2432 25)",
617
+ },
618
+ {
619
+ "desc": "def in srgb",
620
+ "oklch": "oklch(0.6 0.1 25)",
621
+ "hex": "#b46762",
622
+ "oklch_in_srgb": "oklch(0.6 0.1 25)",
623
+ "hex_in_srgb": "#b46762",
624
+ "hex_to_oklch_color_picker": "oklch(59.9% 0.0998 24.4)",
625
+ "hex_in_srgb_to_oklch_color_picker": "oklch(59.9% 0.0998 24.4)",
626
+ },
627
+ {
628
+ "desc": "harmonized red 500 in p3",
629
+ "level": "500",
630
+ "hue": "Red",
631
+ "oklch": "oklch(0.73 0.21 20)",
632
+ "hex": "#ff5f6d",
633
+ "oklch_in_srgb": "oklch(0.73 0.1661 20)",
634
+ "hex_in_srgb": "#ff777b",
635
+ "hex_to_oklch_color_picker": "oklch(69.7% 0.194 18.6)",
636
+ "hex_in_srgb_to_oklch_color_picker": "oklch(73.1% 0.1661 20.3)",
637
+ },
638
+ ]
639
+ import json
640
+ for num, case in enumerate(tests):
641
+ oklch = Oklch.parse(case["oklch"])
642
+ oklch_in_srgb = Oklch.parse(case["oklch_in_srgb"])
643
+ expected_hex = case["hex"]
644
+ actual_hex = oklch_to_hex(oklch)
645
+ text = f"""case {num}
646
+ {json.dumps(case, indent=4)}
647
+ Defined color = {oklch}
648
+ actual hex == expected hex: {actual_hex == expected_hex}
649
+ {actual_hex=}, {expected_hex=}
650
+ hex to oklch via fun = {hex_to_oklch(expected_hex)}
651
+ -------------------------------------------------------------"""
652
+ print(text)
653
+
654
+
655
+ if __name__ == "__main__":
656
+ run_test()
657
+
658
+
659
+
660
+ # ---------------------------------------------------------------------------
661
+ # Original mess
662
+ # ---------------------------------------------------------------------------
663
+ #
664
+ # class Color:
665
+ #
666
+ # # These return a dummy color just so to eliminate an annoying warning.
667
+ # def to_RGB(self) -> "RGB":
668
+ # return RGB(0, 0, 0)
669
+ #
670
+ # def to_HEX(self) -> "HEX":
671
+ # return HEX("#000000")
672
+ #
673
+ # def to_OKLAB(self) -> "OKLAB":
674
+ # return OKLAB(0, 0, 0)
675
+ #
676
+ # def to_OKLCH(self) -> "OKLCH":
677
+ # return OKLCH(0, 0, 0)
678
+ #
679
+ # def is_in_gamut(self) -> bool:
680
+ # return self.to_RGB().is_in_gamut()
681
+ #
682
+ # def __str__(self):
683
+ # return ""
684
+ #
685
+ # # Checks that arg is color
686
+ # @staticmethod
687
+ # def _is_color(arg):
688
+ # if not isinstance(arg, Color):
689
+ # raise ValueError(f"Expected color, received '{type(arg)}'!")
690
+ #
691
+ # # Checks that two colors are close
692
+ # def is_close(self, other):
693
+ # return self.to_HEX().hex_code == other.to_HEX().hex_code
694
+ #
695
+ # # Addition gives the midpoint of the two colors in OKLAB space
696
+ # def __add__(self, other):
697
+ # self._is_color(other)
698
+ #
699
+ # return self.to_OKLAB() + other.to_OKLAB()
700
+ #
701
+ # # Negation gives the complement in OKLAB space
702
+ # def __neg__(self):
703
+ # return self.to_OKLAB().__neg__()
704
+ #
705
+ # # Subtraction gives the midpoint of self and complement of other
706
+ # def __sub__(self, other):
707
+ # self._is_color(other)
708
+ #
709
+ # return self.to_OKLAB() - other.to_OKLAB()
710
+ #
711
+ # # Pipe operator yields the euclidean distance between two colors
712
+ # def __or__(self, other):
713
+ # self._is_color(other)
714
+ #
715
+ # # Convert both colors to oklab:
716
+ # self = self.to_OKLAB()
717
+ # other = other.to_OKLAB()
718
+ #
719
+ # return math.pow(
720
+ # (self.l - other.l) ** 2 + (self.a - other.a) ** 2 + (self.b - other.b) ** 2,
721
+ # 0.5,
722
+ # )
723
+ #
724
+ #
725
+ #
726
+ # ###############################################################################
727
+ # #
728
+ # # Original license for:
729
+ # # - RGB.to_OKLAB()
730
+ # # - RGB._srgb_transfer_function()
731
+ # # - RGB._srgb_transfer_function_inv()
732
+ # # - OKLAB.to_RGB()
733
+ # #
734
+ # # Copyright (c) 2021 Björn Ottosson
735
+ # #
736
+ # # Permission is hereby granted, free of charge, to any person obtaining a
737
+ # # copy of this software and associated documentation files (the "Software"),
738
+ # # to deal in the Software without restriction, including without limitation
739
+ # # the rights to use, copy, modify, merge, publish, distribute, sublicense,
740
+ # # and/or sell copies of the Software, and to permit persons to whom the
741
+ # # Software is furnished to do so, subject to the following conditions:
742
+ # #
743
+ # # The above copyright notice and this permission notice shall be
744
+ # # included in all copies or substantial portions of the Software.
745
+ # #
746
+ # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
747
+ # # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
748
+ # # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
749
+ # # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
750
+ # # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
751
+ # # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
752
+ # # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
753
+ # #
754
+ # ###############################################################################
755
+ #
756
+ #
757
+ # # RGB colors represented as triplets
758
+ # class RGB(Color):
759
+ #
760
+ # def __init__(self, red: int, green: int, blue: int):
761
+ # self.r = red
762
+ # self.g = green
763
+ # self.b = blue
764
+ #
765
+ # def __repr__(self) -> str:
766
+ # return f"rgb({self.r}, {self.g}, {self.b})"
767
+ #
768
+ # def is_close(self, other):
769
+ # return super().is_close(other)
770
+ #
771
+ # # Return type for addition and subtraction is type of first operand
772
+ # def __add__(self, other):
773
+ # return super().__add__(other).to_RGB()
774
+ #
775
+ # def __neg__(self):
776
+ # return super().__neg__().to_RGB()
777
+ #
778
+ # def __sub__(self, other):
779
+ # return super().__sub__(other).to_RGB()
780
+ #
781
+ # def __or__(self, other):
782
+ # return super().__or__(other)
783
+ #
784
+ # # Type Conversions
785
+ # def to_RGB(self) -> Self:
786
+ # return self
787
+ #
788
+ # def to_HEX(self):
789
+ # return HEX(
790
+ # "#{:0>2}{:0>2}{:0>2}".format(_hex(self.r), _hex(self.g), _hex(self.b))
791
+ # )
792
+ #
793
+ # # Functions for converting to linear RGB from standard RGB and vice versa
794
+ # @staticmethod
795
+ # def _srgb_transfer(val: float):
796
+ # """Convert linear RGB to standard RGB."""
797
+ # if val >= 0.0031308:
798
+ # ret = (1.055) * math.pow(val, 1.0 / 2.4) - 0.055
799
+ # else:
800
+ # ret = 12.92 * val
801
+ # return ret
802
+ #
803
+ # @staticmethod
804
+ # def _srgb_transfer_inverse(val: float):
805
+ # if val >= 0.04045:
806
+ # ret = math.pow((val + 0.055) / (1 + 0.055), 2.4)
807
+ # else:
808
+ # ret = val / 12.92
809
+ # return ret
810
+ #
811
+ # def to_OKLAB(self):
812
+ # """Map from RGB to OKLAB."""
813
+ # red = self._srgb_transfer_inverse(self.r / 255)
814
+ # green = self._srgb_transfer_inverse(self.g / 255)
815
+ # blue = self._srgb_transfer_inverse(self.b / 255)
816
+ # exp = 1 / 3
817
+ #
818
+ # l_ = math.pow(
819
+ # 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue,
820
+ # exp,
821
+ # )
822
+ # m_ = math.pow(
823
+ # 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue,
824
+ # exp,
825
+ # )
826
+ # s_ = math.pow(
827
+ # 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue,
828
+ # exp,
829
+ # )
830
+ #
831
+ #
832
+ # return OKLAB(
833
+ # 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
834
+ # 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
835
+ # 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
836
+ # )
837
+ #
838
+ # def to_OKLCH(self):
839
+ # return self.to_OKLAB().to_OKLCH()
840
+ #
841
+ # # Check whether the color is in-gamut
842
+ # def is_in_gamut(self):
843
+ # return max(self.r, self.g, self.b) <= 255 and min(self.r, self.g, self.b) >= 0
844
+ #
845
+ #
846
+ # # RGB colors represented as hex code
847
+ # class HEX(Color):
848
+ # def __init__(self, hex_code):
849
+ # if not hex_code[0] == "#":
850
+ # hex_code = "#" + hex_code
851
+ # self.hex_code = hex_code.upper()
852
+ #
853
+ # def __str__(self):
854
+ # return self.hex_code
855
+ #
856
+ # def is_close(self, other):
857
+ # return super().is_close(other)
858
+ #
859
+ # # Return type for addition and subtraction is type of first operand, unless
860
+ # # the result is an invalid color, as this could result in a mangled hex
861
+ # # code which is ambiguous. Therefore, these are left as RGB.
862
+ # # The below function sorts this out:
863
+ # @staticmethod
864
+ # def __get_valid_hex_or_rgb(color):
865
+ # if color.is_in_gamut():
866
+ # return color.to_HEX()
867
+ # else:
868
+ # return color.to_RGB()
869
+ #
870
+ # def __add__(self, other):
871
+ # return self.__get_valid_hex_or_rgb(super().__add__(other))
872
+ #
873
+ # def __neg__(self):
874
+ # return self.__get_valid_hex_or_rgb(super().__neg__())
875
+ #
876
+ # def __sub__(self, other):
877
+ # return self.__get_valid_hex_or_rgb(super().__sub__(other))
878
+ #
879
+ # def __or__(self, other):
880
+ # return super().__or__(other)
881
+ #
882
+ # # Type Conversions
883
+ # def to_RGB(self):
884
+ # return RGB(
885
+ # int(self.hex_code[1:3], 16),
886
+ # int(self.hex_code[3:5], 16),
887
+ # int(self.hex_code[5:7], 16),
888
+ # )
889
+ #
890
+ # def to_HEX(self):
891
+ # return self
892
+ #
893
+ # def to_OKLAB(self):
894
+ # return self.to_RGB().to_OKLAB()
895
+ #
896
+ # def to_OKLCH(self):
897
+ # return self.to_RGB().to_OKLCH()
898
+ #
899
+ # # Check whether the color is in-gamut
900
+ # def is_in_gamut(self):
901
+ # return super().is_in_gamut()
902
+ #
903
+ #
904
+ # # OKLAB colors represented as triplets
905
+ # class OKLAB(Color):
906
+ # def __init__(self, l, a, b):
907
+ # self.l = l
908
+ # self.a = a
909
+ # self.b = b
910
+ #
911
+ # def __str__(self):
912
+ # return f"oklab({self.l}, {self.a}, {self.b})"
913
+ #
914
+ # def is_close(self, other):
915
+ # return super().is_close(other)
916
+ #
917
+ # # Return type for addition and subtraction is type of first operand
918
+ # def __add__(self, other):
919
+ # l = 0.5 * (self.l + other.l)
920
+ # a = 0.5 * (self.a + other.a)
921
+ # b = 0.5 * (self.b + other.b)
922
+ #
923
+ # return OKLAB(l, a, b)
924
+ #
925
+ # def __neg__(self):
926
+ # return OKLAB(1 - self.l, -self.a, -self.b)
927
+ #
928
+ # def __sub__(self, other):
929
+ # return self + other.__neg__()
930
+ #
931
+ # def __or__(self, other):
932
+ # return super().__or__(other)
933
+ #
934
+ # # Type Conversions
935
+ # def to_RGB(self):
936
+ # """OKLAB to RGB transform."""
937
+ # _l, a, b = self.l, self.a, self.b
938
+ # l3 = math.pow(_l + 0.3963377774 * a + 0.2158037573 * b, 3)
939
+ # m3 = math.pow(_l - 0.1055613458 * a - 0.0638541728 * b, 3)
940
+ # s3 = math.pow(_l - 0.0894841775 * a - 1.2914855480 * b, 3)
941
+ #
942
+ #
943
+ # return RGB(
944
+ # _iround(
945
+ # RGB._srgb_transfer(
946
+ # +4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3
947
+ # )
948
+ # * 255
949
+ # ),
950
+ # _iround(
951
+ # RGB._srgb_transfer(
952
+ # -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3
953
+ # )
954
+ # * 255
955
+ # ),
956
+ # _iround(
957
+ # RGB._srgb_transfer(
958
+ # -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3
959
+ # )
960
+ # * 255
961
+ # ),
962
+ # )
963
+ #
964
+ # def to_HEX(self):
965
+ # return self.to_RGB().to_HEX()
966
+ #
967
+ # def to_OKLAB(self):
968
+ # return self
969
+ #
970
+ # def to_OKLCH(self):
971
+ # c = math.sqrt(self.a**2 + self.b**2)
972
+ # h = math.degrees(math.atan2(self.b, self.a))
973
+ # if h < 0:
974
+ # h += 360
975
+ #
976
+ # return OKLCH(self.l, c, h)
977
+ #
978
+ # def is_in_gamut(self):
979
+ # return super().is_in_gamut()
980
+ #
981
+ #
982
+ # class OKLCH(Color):
983
+ #
984
+ # def __init__(self, lightness: float, chroma: float, hue: float):
985
+ # self.l = lightness
986
+ # self.c = chroma
987
+ # self.h = hue
988
+ #
989
+ # def __repr__(self) -> str:
990
+ # return "oklch({self.l} {self.c} {self.h})"
991
+ #
992
+ # def is_close(self, other):
993
+ # return super().is_close(other)
994
+ #
995
+ # # Return type for addition and subtraction is type of first operand
996
+ # def __add__(self, other):
997
+ # return super().__add__(other).to_OKLCH()
998
+ #
999
+ # def __neg__(self):
1000
+ # return super().__neg__().to_OKLCH()
1001
+ #
1002
+ # def __sub__(self, other):
1003
+ # return super().__sub__(other).to_OKLCH()
1004
+ #
1005
+ # def __or__(self, other):
1006
+ # return super().__or__(other)
1007
+ #
1008
+ # # Type Conversions
1009
+ # def to_RGB(self):
1010
+ # return self.to_OKLAB().to_RGB()
1011
+ #
1012
+ # def to_HEX(self):
1013
+ # return self.to_RGB().to_HEX()
1014
+ #
1015
+ # @staticmethod
1016
+ # def _get_normalized_ab(hue: float):
1017
+ # a = math.cos(math.radians(hue))
1018
+ # b = math.sin(math.radians(hue))
1019
+ #
1020
+ # return a, b
1021
+ #
1022
+ # def to_OKLAB(self):
1023
+ # a, b = self._get_normalized_ab(self.h)
1024
+ # return OKLAB(self.l, a * self.c, b * self.c)
1025
+ #
1026
+ # def to_OKLCH(self):
1027
+ # return self
1028
+ #
1029
+ # # Check whether the color is in-gamut
1030
+ # def is_in_gamut(self):
1031
+ # return super().is_in_gamut()
1032
+
1033
+ # # Returns a css string which rounds in such a way as to guarantee an
1034
+ # # in-gamut color (presuming the original color was in-gamut, of course)
1035
+ # def css_string(self):
1036
+ # # Find which way is safe to round l
1037
+ # cusp = find_cusp(hue=self.h)
1038
+ # if self.l > cusp.l:
1039
+ # li = _floor(self.l, 4)
1040
+ # else:
1041
+ # li = _ceil(self.l, 4)
1042
+ #
1043
+ # # Always safe to floor c
1044
+ # c = _floor(self.c, 3)
1045
+ #
1046
+ # # Doesn't matter how h is rounded; it can go directly in format string
1047
+ # return "oklch({:.2%} {:.3f} {:.2f})".format(li, c, self.h)
1048
+ #
1049
+ #
1050
+ #
1051
+ #
1052
+
1053
+ # ----------------------------------------------------------------------------
1054
+ # # Several of the below functions are sourced from Björn Ottosson's blog posts
1055
+ # # which originally defined the OKLAB & OKLCH color spaces. For full
1056
+ # # information on how these functions work, you should read those posts. Other
1057
+ # # than translation, I have made only minimal changes to them.
1058
+ # #
1059
+ # # The original code can be found at:
1060
+ # # https://bottosson.github.io/posts/gamutclipping/
1061
+ # #
1062
+ # ----------------------------------------------------------------------------
1063
+ # ###############################################################################
1064
+ # #
1065
+ # # Copyright (c) 2021 Björn Ottosson
1066
+ # #
1067
+ # # Permission is hereby granted, free of charge, to any person obtaining a
1068
+ # # copy of this software and associated documentation files (the "Software"),
1069
+ # # to deal in the Software without restriction, including without limitation
1070
+ # # the rights to use, copy, modify, merge, publish, distribute, sublicense,
1071
+ # # and/or sell copies of the Software, and to permit persons to whom the
1072
+ # # Software is furnished to do so, subject to the following conditions:
1073
+ # #
1074
+ # # The above copyright notice and this permission notice shall be
1075
+ # # included in all copies or substantial portions of the Software.
1076
+ # #
1077
+ # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
1078
+ # # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
1079
+ # # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
1080
+ # # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
1081
+ # # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
1082
+ # # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
1083
+ # # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1084
+ # #
1085
+ # ###############################################################################
1086
+ #
1087
+ M_ab_to_lms: Matrix3x2 = (
1088
+ (0.3963377774, +0.2158037573),
1089
+ (-0.1055613458, -0.0638541728),
1090
+ (-0.0894841775, -1.2914855480),
1091
+ )
1092
+
1093
+ def _max_saturation(a: float, b: float):
1094
+ """Determine the maximum saturation provided the Lab (a, b)-coordinates
1095
+ on the unit circle.
1096
+ Max saturation will be when one of r, g or b goes below zero.
1097
+ """
1098
+ if a == 0 and b == 0:
1099
+ return 0
1100
+
1101
+ k_lms: Vector3d = linear_trans_2d_to_3d(M_ab_to_lms, (a, b))
1102
+ # original
1103
+ # k_l = +0.3963377774 * a + 0.2158037573 * b
1104
+ # k_m = -0.1055613458 * a - 0.0638541728 * b
1105
+ # k_s = -0.0894841775 * a - 1.2914855480 * b
1106
+
1107
+ # Approximate max saturation using a polynomial whose coefficients
1108
+ # depend on which component goes below zero first.
1109
+ w_lms: Vector3d
1110
+ if (-1.88170328 * a - 0.80936493 * b) > 1:
1111
+ # Red component
1112
+ k0 = +1.19086277
1113
+ k1 = +1.76576728
1114
+ k2 = +0.59662641
1115
+ k3 = +0.75515197
1116
+ k4 = +0.56771245
1117
+ w_lms = (+4.0767416621, -3.3077115913, +0.2309699292)
1118
+ elif (1.81444104 * a - 1.19445276 * b) > 1:
1119
+ # Green component
1120
+ k0 = +0.73956515
1121
+ k1 = -0.45954404
1122
+ k2 = +0.08285427
1123
+ k3 = +0.12541070
1124
+ k4 = +0.14503204
1125
+ w_lms = (-1.2684380046, +2.6097574011, -0.3413193965)
1126
+ else:
1127
+ # Blue component
1128
+ k0 = +1.35733652
1129
+ k1 = -0.00915799
1130
+ k2 = -1.15130210
1131
+ k3 = -0.50559606
1132
+ k4 = +0.00692167
1133
+ w_lms = (-0.0041960863, -0.7034186147, 1.7076147010)
1134
+ S = k0 + (k1 * a) + (k2 * b) + (k3 * a**2) + (k4 * a * b)
1135
+
1136
+
1137
+
1138
+ # Apply Halley's method to get closer. Typically error is less than 10e6,
1139
+ # except for some blue hues where dS/dh is close to infinite.
1140
+ # Between one and three iterations steps should suffice.
1141
+ for _ in range(3):
1142
+ lms_: Vector3d = vmap3d(lambda x: S*x + 1, k_lms)
1143
+ # lms_2: Vector3d = square3d(lms_)
1144
+ # lms_3: Vector3d = cube3d(lms_)
1145
+ # l_ = 1 + S * k_l
1146
+ # m_ = 1 + S * k_m
1147
+ # s_ = 1 + S * k_s
1148
+
1149
+
1150
+ # lms_**3
1151
+ # original
1152
+ # l3 = l_ * l_ * l_
1153
+ # m3 = m_ * m_ * m_
1154
+ # s3 = s_ * s_ * s_
1155
+
1156
+ # original
1157
+ # l_dS = 3 * k_l * (l_ * l_)
1158
+ # m_dS = 3 * k_m * (m_ * m_)
1159
+ # s_dS = 3 * k_s * (s_ * s_)
1160
+ # 3(k_lms*lms^2)
1161
+ lms_dS: Vector3d = smul3d(3, vmul3d(k_lms, square3d(lms_)))
1162
+
1163
+ # original
1164
+ # l_dS2 = 6 * (k_l * k_l) * l_
1165
+ # m_dS2 = 6 * (k_m * k_m) * m_
1166
+ # s_dS2 = 6 * (k_s * k_s) * s_
1167
+ lms_dS2: Vector3d = smul3d(6, vmul3d(square3d(k_lms), lms_))
1168
+
1169
+
1170
+ # f = (wl, ws, wm).lms_3
1171
+ # f1 = (wl, ws, wm).dS
1172
+ # f2 = (wl, ws, wm).dS2
1173
+ f = dot3d(w_lms, cube3d(lms_))
1174
+ f1 = dot3d(w_lms, lms_dS)
1175
+ f2 = dot3d(w_lms, lms_dS2)
1176
+ # original
1177
+ # f = (wl * l3) + (wm * m3) + (ws * s3)
1178
+ # f1 = (wl * l_dS) + wm * m_dS + (ws * s_dS)
1179
+ # f2 = (wl * l_dS2) + (wm * m_dS2) + (ws * s_dS2)
1180
+
1181
+ S = S - (f * f1) / (f1**2 - (0.5 * f * f2))
1182
+ return S
1183
+
1184
+
1185
+ def find_cusp(hue: float) -> Oklch:
1186
+ a, b = hue_to_ab(hue)
1187
+ S_cusp = _max_saturation(a, b)
1188
+ lrgb = oklab_to_linear_rgb(Oklab(1, S_cusp * a, S_cusp * b))[:3]
1189
+ L_cusp = math.cbrt(1 / max(lrgb))
1190
+ return Oklch(L_cusp, L_cusp * S_cusp, hue)
1191
+ # C_cusp = L_cusp * S_cusp
1192
+
1193
+ # Convert to linear sRGB to find the first point where at least one of r,g,
1194
+ # or b >= 1:
1195
+ # rgb_at_max = max(oklab_to_linear_rgb(Oklab(1, S_cusp * a, S_cusp * b))[:3])
1196
+ # L_cusp = math.cbrt(1 / rgb_at_max[:3]))
1197
+ # # original: it's just oklab_to_linear_srgb
1198
+ # r = _srgb_transfer_inverse(rgb_at_max.red / 255)
1199
+ # g = RGB._srgb_transfer_inverse(rgb_at_max.g / 255)
1200
+ # b = RGB._srgb_transfer_inverse(rgb_at_max.b / 255)
1201
+ # L_cusp = math.pow(1.0 / max(rgb_at_max.r, rgb_at_max.g, rgb_at_max.b), 1 / 3)
1202
+ # C_cusp = L_cusp * S_cusp
1203
+ #
1204
+ # return OKLCH(L_cusp, C_cusp, hue)
1205
+
1206
+
1207
+ # class GamutIntersectors:
1208
+ # """A collection of functions that map define lines of intersection in
1209
+ # the (l, c, h=h1) oklch plane P_h1 for a fixed hue h1. Given a point
1210
+ # (l1, c1) in P_h1 the problem is to find a corresponding point (l0, c0) in
1211
+ # P_h1, perhaps depending on (l1, c1), and a value t such that the
1212
+ # point (l_t, c_t) = t(l1, c1) + (1-t)(l0, c0) is on the gamut's roughly
1213
+ # triangular surface.
1214
+ #
1215
+ # a given point (L1, C1) in the oklch
1216
+ # plane a fixed hue h1"""
1217
+
1218
+ # wip reworking of original version that was more a translation from blog post
1219
+ # def _find_gamut_intersection(
1220
+ # L1, C1, color=None, hue=None, L0=None, method="hue_dependent"
1221
+ # ):
1222
+ # # L = L0 * (1 - t) + t * L1
1223
+ # # C = t * C1
1224
+ # # a and b must be normalized so a^2 + b^2 == 1
1225
+ # # I've similarly made this function more flexible for my own ease of use, even
1226
+ # # if it is not intended to be user-facing.
1227
+ # # The minimum information required is L1, C1, and some way of specifying hue.
1228
+ # # L0 is usually not required, since it can be inferred from the method;
1229
+ # # however, an option is given to provide an explicit L0 with method 'manual'.
1230
+ # # Finds intersection of the line defined by
1231
+ # # L = L0 * (1 - t) + t * L1;
1232
+ # # C = t * C1;
1233
+ # col = "oklch(0.86 0.2 25)"
1234
+ # col = "oklch(0.86 0.0744 25)"
1235
+ #
1236
+ #
1237
+ # # Either color or hue may be provided, but exactly one is required.
1238
+ # assert (color is None) ^ (hue is None), (
1239
+ # "Exactly one of color or hue must be provided!"
1240
+ # )
1241
+ #
1242
+ # if hue is not None:
1243
+ # assert isinstance(hue, (float, int)), f"Expected number, received {type(hue)}!"
1244
+ #
1245
+ # else:
1246
+ # assert isinstance(color, Color), (
1247
+ # f"Expected color, received {type(color)}!"
1248
+ # )
1249
+ # if not isinstance(color, OKLCH):
1250
+ # color = color.to_OKLCH()
1251
+ # hue = color.h
1252
+ #
1253
+ # # a and b must be normalized so a^2 + b^2 == 1
1254
+ # a, b = OKLCH._get_normalized_ab(hue)
1255
+ #
1256
+ # # Find the cusp of the gamut triangle
1257
+ # if abs(hue - 264) >= 1:
1258
+ # cusp = find_cusp(hue=hue)
1259
+ # else:
1260
+ # # This handles a strange case with blues where it can converge
1261
+ # # out-of-gamut, resulting in an infinite loop.
1262
+ # cusp = HEX("#023BFB").to_OKLCH()
1263
+ #
1264
+ # # Manual method allows for an explicit L0 value.
1265
+ # if method == "manual":
1266
+ # if L0 is None:
1267
+ # raise ValueError("L0 must be explicitly provided with method 'manual'.")
1268
+ # else:
1269
+ # if L0 is not None:
1270
+ # raise ValueError("L0 cannot be set explicitly unless using method 'manual'!")
1271
+ #
1272
+ # # The other methods specify how L0 should be set.
1273
+ # # Method 'hue_dependent' moves the color towards the point
1274
+ # # (cusp.l, 0, hue) until it intersects the gamut, which is default.
1275
+ # if method == "hue_dependent":
1276
+ # L0 = cusp.l
1277
+ #
1278
+ # # Method 'hue_independent' moves the color towards medium grey.
1279
+ # elif method == "hue_independent":
1280
+ # L0 = 0.5
1281
+ #
1282
+ # # Method 'preserve_lightness' does not alter lightness as long as it's
1283
+ # # a valid value.
1284
+ # elif method == "preserve_lightness":
1285
+ # L0 = min(1, max(0, L1))
1286
+ #
1287
+ # else:
1288
+ # raise ValueError(f"Unknown method: '{method}'!")
1289
+ #
1290
+ # # Find the intersection for upper and lower half separately
1291
+ # if ((L1 - L0) * cusp.c - (cusp.l - L0) * C1) <= 0.0:
1292
+ # # Lower half
1293
+ #
1294
+ # t = cusp.c * L0 / (C1 * cusp.l + cusp.c * (L0 - L1))
1295
+ # output = OKLCH(L0 * (1 - t) + t * L1, t * C1, hue)
1296
+ # else:
1297
+ # # Upper half
1298
+ #
1299
+ # # First intersect with triangle
1300
+ # t = cusp.c * (L0 - 1.0) / (C1 * (cusp.l - 1.0) + cusp.c * (L0 - L1))
1301
+ #
1302
+ # # Then one step Halley's method
1303
+ # dL = L1 - L0
1304
+ # dC = C1
1305
+ #
1306
+ # k_l = +0.3963377774 * a + 0.2158037573 * b
1307
+ # k_m = -0.1055613458 * a - 0.0638541728 * b
1308
+ # k_s = -0.0894841775 * a - 1.2914855480 * b
1309
+ #
1310
+ # l_dt = dL + dC * k_l
1311
+ # m_dt = dL + dC * k_m
1312
+ # s_dt = dL + dC * k_s
1313
+ #
1314
+ # # If higher accuracy is required, 2 or 3 iterations of the following
1315
+ # # block can be used:
1316
+ # output = OKLCH(L0 * (1 - t) + t * L1, t * C1, hue)
1317
+ # while not output.is_in_gamut():
1318
+ # L = L0 * (1.0 - t) + t * L1
1319
+ # C = t * C1
1320
+ #
1321
+ # l_ = L + C * k_l
1322
+ # m_ = L + C * k_m
1323
+ # s_ = L + C * k_s
1324
+ #
1325
+ # l3 = l_ * l_ * l_
1326
+ # m3 = m_ * m_ * m_
1327
+ # s3 = s_ * s_ * s_
1328
+ #
1329
+ # ldt = 3 * l_dt * l_ * l_
1330
+ # mdt = 3 * m_dt * m_ * m_
1331
+ # sdt = 3 * s_dt * s_ * s_
1332
+ #
1333
+ # ldt2 = 6 * l_dt * l_dt * l_
1334
+ # mdt2 = 6 * m_dt * m_dt * m_
1335
+ # sdt2 = 6 * s_dt * s_dt * s_
1336
+ #
1337
+ # r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3 - 1
1338
+ # r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt
1339
+ # r2 = 4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2
1340
+ #
1341
+ # u_r = r1 / (r1 * r1 - 0.5 * r * r2)
1342
+ # t_r = -r * u_r
1343
+ #
1344
+ # g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3 - 1
1345
+ # g1 = -1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt
1346
+ # g2 = -1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2
1347
+ #
1348
+ # u_g = g1 / (g1 * g1 - 0.5 * g * g2)
1349
+ # t_g = -g * u_g
1350
+ #
1351
+ # b = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3 - 1
1352
+ # b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.7076147010 * sdt
1353
+ # b2 = -0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.7076147010 * sdt2
1354
+ #
1355
+ # u_b = b1 / (b1 * b1 - 0.5 * b * b2)
1356
+ # t_b = -b * u_b
1357
+ #
1358
+ # t_r = t_r if u_r >= 0.0 else sys.float_info.max
1359
+ # t_g = t_g if u_g >= 0.0 else sys.float_info.max
1360
+ # t_b = t_b if u_b >= 0.0 else sys.float_info.max
1361
+ #
1362
+ # t += min(t_r, t_g, t_b)
1363
+ # output = OKLCH(L0 * (1 - t) + t * L1, t * C1, hue)
1364
+ #
1365
+ # return output
1366
+
1367
+ # def _original_find_gamut_intersection(
1368
+ # L1, C1, color=None, hue=None, L0=None, method="hue_dependent"
1369
+ # ):
1370
+ # # L = L0 * (1 - t) + t * L1
1371
+ # # C = t * C1
1372
+ # # a and b must be normalized so a^2 + b^2 == 1
1373
+ # # I've similarly made this function more flexible for my own ease of use, even
1374
+ # # if it is not intended to be user-facing.
1375
+ # # The minimum information required is L1, C1, and some way of specifying hue.
1376
+ # # L0 is usually not required, since it can be inferred from the method;
1377
+ # # however, an option is given to provide an explicit L0 with method 'manual'.
1378
+ # # Finds intersection of the line defined by
1379
+ # # L = L0 * (1 - t) + t * L1;
1380
+ # # C = t * C1;
1381
+ #
1382
+ # # Either color or hue may be provided, but exactly one is required.
1383
+ # assert (color is None) ^ (hue is None), (
1384
+ # "Exactly one of color or hue must be provided!"
1385
+ # )
1386
+ #
1387
+ # if hue is not None:
1388
+ # assert isinstance(hue, (float, int)), f"Expected number, received {type(hue)}!"
1389
+ #
1390
+ # else:
1391
+ # assert isinstance(color, Color), (
1392
+ # f"Expected color, received {type(color)}!"
1393
+ # )
1394
+ # if not isinstance(color, OKLCH):
1395
+ # color = color.to_OKLCH()
1396
+ # hue = color.h
1397
+ #
1398
+ # # a and b must be normalized so a^2 + b^2 == 1
1399
+ # a, b = OKLCH._get_normalized_ab(hue)
1400
+ #
1401
+ # # Find the cusp of the gamut triangle
1402
+ # if abs(hue - 264) >= 1:
1403
+ # cusp = find_cusp(hue=hue)
1404
+ # else:
1405
+ # # This handles a strange case with blues where it can converge
1406
+ # # out-of-gamut, resulting in an infinite loop.
1407
+ # cusp = HEX("#023BFB").to_OKLCH()
1408
+ #
1409
+ # # Manual method allows for an explicit L0 value.
1410
+ # if method == "manual":
1411
+ # if L0 is None:
1412
+ # raise ValueError("L0 must be explicitly provided with method 'manual'.")
1413
+ # else:
1414
+ # if L0 is not None:
1415
+ # raise ValueError("L0 cannot be set explicitly unless using method 'manual'!")
1416
+ #
1417
+ # # The other methods specify how L0 should be set.
1418
+ # # Method 'hue_dependent' moves the color towards the point
1419
+ # # (cusp.l, 0, hue) until it intersects the gamut, which is default.
1420
+ # if method == "hue_dependent":
1421
+ # L0 = cusp.l
1422
+ #
1423
+ # # Method 'hue_independent' moves the color towards medium grey.
1424
+ # elif method == "hue_independent":
1425
+ # L0 = 0.5
1426
+ #
1427
+ # # Method 'preserve_lightness' does not alter lightness as long as it's
1428
+ # # a valid value.
1429
+ # elif method == "preserve_lightness":
1430
+ # L0 = min(1, max(0, L1))
1431
+ #
1432
+ # else:
1433
+ # raise ValueError(f"Unknown method: '{method}'!")
1434
+ #
1435
+ # # Find the intersection for upper and lower half separately
1436
+ # if ((L1 - L0) * cusp.c - (cusp.l - L0) * C1) <= 0.0:
1437
+ # # Lower half
1438
+ #
1439
+ # t = cusp.c * L0 / (C1 * cusp.l + cusp.c * (L0 - L1))
1440
+ # output = OKLCH(L0 * (1 - t) + t * L1, t * C1, hue)
1441
+ # else:
1442
+ # # Upper half
1443
+ #
1444
+ # # First intersect with triangle
1445
+ # t = cusp.c * (L0 - 1.0) / (C1 * (cusp.l - 1.0) + cusp.c * (L0 - L1))
1446
+ #
1447
+ # # Then one step Halley's method
1448
+ # dL = L1 - L0
1449
+ # dC = C1
1450
+ #
1451
+ # k_l = +0.3963377774 * a + 0.2158037573 * b
1452
+ # k_m = -0.1055613458 * a - 0.0638541728 * b
1453
+ # k_s = -0.0894841775 * a - 1.2914855480 * b
1454
+ #
1455
+ # l_dt = dL + dC * k_l
1456
+ # m_dt = dL + dC * k_m
1457
+ # s_dt = dL + dC * k_s
1458
+ #
1459
+ # # If higher accuracy is required, 2 or 3 iterations of the following
1460
+ # # block can be used:
1461
+ # output = OKLCH(L0 * (1 - t) + t * L1, t * C1, hue)
1462
+ # while not output.is_in_gamut():
1463
+ # L = L0 * (1.0 - t) + t * L1
1464
+ # C = t * C1
1465
+ #
1466
+ # l_ = L + C * k_l
1467
+ # m_ = L + C * k_m
1468
+ # s_ = L + C * k_s
1469
+ #
1470
+ # l3 = l_ * l_ * l_
1471
+ # m3 = m_ * m_ * m_
1472
+ # s3 = s_ * s_ * s_
1473
+ #
1474
+ # ldt = 3 * l_dt * l_ * l_
1475
+ # mdt = 3 * m_dt * m_ * m_
1476
+ # sdt = 3 * s_dt * s_ * s_
1477
+ #
1478
+ # ldt2 = 6 * l_dt * l_dt * l_
1479
+ # mdt2 = 6 * m_dt * m_dt * m_
1480
+ # sdt2 = 6 * s_dt * s_dt * s_
1481
+ #
1482
+ # r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3 - 1
1483
+ # r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt
1484
+ # r2 = 4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2
1485
+ #
1486
+ # u_r = r1 / (r1 * r1 - 0.5 * r * r2)
1487
+ # t_r = -r * u_r
1488
+ #
1489
+ # g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3 - 1
1490
+ # g1 = -1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt
1491
+ # g2 = -1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2
1492
+ #
1493
+ # u_g = g1 / (g1 * g1 - 0.5 * g * g2)
1494
+ # t_g = -g * u_g
1495
+ #
1496
+ # b = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3 - 1
1497
+ # b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.7076147010 * sdt
1498
+ # b2 = -0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.7076147010 * sdt2
1499
+ #
1500
+ # u_b = b1 / (b1 * b1 - 0.5 * b * b2)
1501
+ # t_b = -b * u_b
1502
+ #
1503
+ # t_r = t_r if u_r >= 0.0 else sys.float_info.max
1504
+ # t_g = t_g if u_g >= 0.0 else sys.float_info.max
1505
+ # t_b = t_b if u_b >= 0.0 else sys.float_info.max
1506
+ #
1507
+ # t += min(t_r, t_g, t_b)
1508
+ # output = OKLCH(L0 * (1 - t) + t * L1, t * C1, hue)
1509
+ #
1510
+ # return output
1511
+ #
1512
+ #
1513
+ # ###############################################################################
1514
+ # #
1515
+ # # The following two functions find the intersections of the line defined by:
1516
+ # # L = color.l * (1 - t1) + t1 * L1
1517
+ # # C = color.c * (1 - t1) + t1 * C1
1518
+ # # where either L1 == color.l or C1 == color.c. These are useful points for
1519
+ # # performing lightening or saturating operations within a given hue.
1520
+ # #
1521
+ # # This differs from the above function in the C term, as for our purposes C0
1522
+ # # may not be equal to 0. This adds an additional unknown, but when
1523
+ # # considering the additional constraints:
1524
+ # # (L, C) = (0, 0) * (1 - t2) + t2 * (L_cusp, C_cusp); (lower half)
1525
+ # # OR
1526
+ # # (L, C) = (L_cusp, C_cusp) * (1 - t2) + t2 * (1, 0); (upper half)
1527
+ # # we end up with five unknowns and five functions nonetheless.
1528
+ # #
1529
+ # ###############################################################################
1530
+ # #
1531
+ # # For the first of our two functions, we take some color with chroma color.c
1532
+ # # which is less than the maximum possible in-gamut chroma for the given
1533
+ # # lightness color.l and hue color.h. This can be used with the trivially
1534
+ # # found minimum chroma of 0 to perform relative operations on a color's
1535
+ # # chroma.
1536
+ # # Therefore, this function solves for chroma C given L1 == color.l:
1537
+ # # => L = color.l
1538
+ # # then in the lower half case:
1539
+ # # (color.l, C) = t2 * (L_cusp, C_cusp)
1540
+ # # => t2 = (color.l, C) / (L_cusp, C_cusp) where (L_cusp, C_cusp) != 0
1541
+ # # => C / C_cusp = color.l / L_cusp
1542
+ # # => C = C_cusp * (color.l / L_cusp)
1543
+ # # and in the upper half case:
1544
+ # # (color.l, C) = (L_cusp, C_cusp) * (1 - t2) + t2 * (1, 0)
1545
+ # # => (color.l, C) - (L_cusp, C_cusp) = t2 * ((1, 0) - (L_cusp, C_cusp))
1546
+ # # => t2 = ((color.l, C) - (L_cusp, C_cusp)) / (1 - L_cusp, -C_cusp)
1547
+ # # where L_cusp != 1 && C_cusp != 0
1548
+ # # => (C - C_cusp) / C_cusp = (L_cusp - color.l) / (1 - L_cusp)
1549
+ # # => C = C_cusp * (L_cusp - color.l) / (1 - L_cusp) + C_cusp
1550
+ # # = C_cusp * (1 + (L_cusp - color.l) / (1 - L_cusp))
1551
+ # # = C_cusp * (1 - color.l) / (1 - L_cusp)
1552
+ # #
1553
+ # ###############################################################################
1554
+ # def _find_chroma_max(color):
1555
+ # # First, get the cusp
1556
+ # cusp = find_cusp(color=color)
1557
+ #
1558
+ # # Next, we consider whether our lightness places us in the upper or lower
1559
+ # # half:
1560
+ # if color.l <= cusp.l:
1561
+ # # Lower half
1562
+ # C = cusp.c * (color.l / cusp.l)
1563
+ # else:
1564
+ # # Upper half
1565
+ # C = cusp.c * (1 - color.l) / (1 - cusp.l)
1566
+ #
1567
+ # # Correct for the concavity of the upper half.
1568
+ # C = _find_gamut_intersection(
1569
+ # color.l, C, color=color, method="preserve_lightness"
1570
+ # ).c
1571
+ #
1572
+ # return C
1573
+ #
1574
+ #
1575
+ # ###############################################################################
1576
+ # #
1577
+ # # For the second of our two functions, we take some color with lightness
1578
+ # # color.l which is between the minimum and maximum possible in-gamut
1579
+ # # lightness for the given chroma color.c and hue color.h. These can be used
1580
+ # # to perform relative operations on a color's lightness.
1581
+ # # Therefore, this function solves for lightness L given C1 == color.c:
1582
+ # # => C = color.c
1583
+ # # then in the lower half case:
1584
+ # # (L, color.c) = t2 * (L_cusp, C_cusp)
1585
+ # # => t2 = (L, color.c) / (L_cusp, C_cusp) where (L_cusp, C_cusp) != 0
1586
+ # # => L / L_cusp = color.c / C_cusp
1587
+ # # => L = L_cusp * (color.c / C_cusp)
1588
+ # # and in the upper half case:
1589
+ # # (L, color.c) = (L_cusp, C_cusp) * (1 - t2) + t2 * (1, 0)
1590
+ # # => (L, color.c) - (L_cusp, C_cusp) = t2 * ((1, 0) - (L_cusp, C_cusp))
1591
+ # # => t2 = ((L, color.c) - (L_cusp, C_cusp)) / (1 - L_cusp, -C_cusp)
1592
+ # # where L_cusp != 1 && C_cusp != 0
1593
+ # # => (L - L_cusp) / (1 - L_cusp) = (C_cusp - color.c) / C_cusp
1594
+ # # = 1 - color.c / C_cusp
1595
+ # # => L = (1 - L_cusp) * (1 - color.c / C_cusp) + L_cusp
1596
+ # # => L = 1 - (1 - L_cusp) * (color.c / C_cusp)
1597
+ # #
1598
+ # # Since the lightness intersects at two points, we are interested in both the
1599
+ # # lower half and the upper half.
1600
+ # #
1601
+ # ###############################################################################
1602
+ # def _find_lightness_bounds(color):
1603
+ # # First, get the cusp
1604
+ # cusp = find_cusp(color=color)
1605
+ #
1606
+ # # Lower half
1607
+ # L1 = cusp.l * (color.c / cusp.c)
1608
+ #
1609
+ # # Upper half
1610
+ # L2 = 1 - (1 - cusp.l) * (color.c / cusp.c)
1611
+ #
1612
+ # # Correct for the concavity of the upper half.
1613
+ # # By manually setting L0 to an arbitrarily large negative number, we can
1614
+ # # easily approximate moving horizontally.
1615
+ # L2 = _find_gamut_intersection(L2, color.c, color=color, method="manual", L0=-1000).l
1616
+ #
1617
+ # return (L1, L2)
1618
+ #
1619
+ #
1620
+ # # A simple lerp
1621
+ # def _lerp(t, a, b):
1622
+ # return a * (1 - t) + b * t
1623
+ #
1624
+ #
1625
+ # ###############################################################################
1626
+ # #
1627
+ # # Public Functions
1628
+ # #
1629
+ # ###############################################################################
1630
+ # #
1631
+ # # All of the below functions implement a similar function header and type
1632
+ # # checking process so that users are able to specify the necessary
1633
+ # # information in whatever format is convenient to them, without compromising
1634
+ # # the stability of the code.
1635
+ # # Generally speaking, a function which requires lightness, hue, or chroma can
1636
+ # # receive either explicit values for each, or a color object with the
1637
+ # # desired properties. A color object need not be OKLCH, and thus is the
1638
+ # # intended way to pass RGB/HEX Many of the functions essentially
1639
+ # # exist to tweak one of the characteristics of an OKLCH color by
1640
+ # # interpolating within certain bounds, and when that is the case the first
1641
+ # # argument is always the parameter for that lerp. Furthermore, a method can
1642
+ # # often be specified between relative, which operates from the provided
1643
+ # # color to the extremum in-gamut color for the given axis, and absolute,
1644
+ # # which only considers the extrema.
1645
+ # #
1646
+ # ###############################################################################
1647
+ #
1648
+ #
1649
+ # # Type-checking used for all of the below:
1650
+ # def __get_OKLCH_if_color(arg):
1651
+ # Color._is_color(arg)
1652
+ # return arg.to_OKLCH()
1653
+ #
1654
+ #
1655
+ # # Lerps the chroma for the given color. Negative t dechromatizes if the method
1656
+ # # is relative
1657
+ # def chromatize(t, color=None, hue=None, lightness=None, method="relative"):
1658
+ # assert (color is None) ^ (hue is None), (
1659
+ # "Exactly one of color or hue must be provided!"
1660
+ # )
1661
+ #
1662
+ # if hue is not None:
1663
+ # if not isinstance(hue, (float, int)):
1664
+ # raise ValueError(f"Expected number, received {type(lightness)}!")
1665
+ #
1666
+ # assert lightness is not None, "Lightness must be specified with explicit hue!"
1667
+ # if not isinstance(lightness, (float, int)):
1668
+ # raise ValueError(f"Expected number, received {type(lightness)}!")
1669
+ #
1670
+ # assert method == "absolute", (
1671
+ # "Explicit hue can only be specified with method 'relative'!"
1672
+ # )
1673
+ # # Generate a color object with the given parameters
1674
+ # color = OKLCH(lightness, 0.0, hue)
1675
+ #
1676
+ # else:
1677
+ # color = __get_OKLCH_if_color(color)
1678
+ #
1679
+ # # Find max chroma
1680
+ # max = _find_chroma_max(color)
1681
+ #
1682
+ # if method == "relative":
1683
+ # if t < -1 or t > 1:
1684
+ # raise ValueError("t should be in the range [-1,1] for method 'relative'!")
1685
+ #
1686
+ # # Interpolate between current c and maximum
1687
+ # if t >= 0:
1688
+ # C = _lerp(t, color.c, max)
1689
+ #
1690
+ # # Negative t means we interpolate towards the minimum (0) instead
1691
+ # else:
1692
+ # C = _lerp(-t, color.c, 0)
1693
+ #
1694
+ # elif method == "absolute":
1695
+ # if t < 0 or t > 1:
1696
+ # raise ValueError("t should be in the range [0,1] for method 'absolute'!")
1697
+ #
1698
+ # # In absolute mode we interpolate between minimum (0) and max
1699
+ # C = _lerp(t, 0, max)
1700
+ #
1701
+ # else:
1702
+ # raise ValueError(f"""Unknown method: '{method}'!
1703
+ # Valid methods are 'relative' and 'absolute'.""")
1704
+ #
1705
+ # ret = OKLCH(color.l, C, color.h)
1706
+ # # Make sure that the color is in-gamut
1707
+ # if ret.is_in_gamut():
1708
+ # return ret
1709
+ # else:
1710
+ # # Clip it into gamut
1711
+ # return gamut_clip_preserve_lightness(ret)
1712
+ #
1713
+ #
1714
+ # # Simply reverses the direction of chromatize
1715
+ # def dechromatize(t, color=None, hue=None, lightness=None, method="relative"):
1716
+ # # Negate t and chromatize
1717
+ # if method == "relative":
1718
+ # return chromatize(-t, color=color, hue=hue, lightness=lightness)
1719
+ #
1720
+ # # Reverse t and chromatize
1721
+ # elif method == "absolute":
1722
+ # return chromatize(
1723
+ # 1 - t, color=color, hue=hue, lightness=lightness, method="absolute"
1724
+ # )
1725
+ #
1726
+ # else:
1727
+ # raise ValueError(f"""Unknown method: '{method}'!
1728
+ # Valid methods are 'relative' and 'absolute'.""")
1729
+ #
1730
+ #
1731
+ # # Chromatize, while technically more correct, is not a very appealing name, so
1732
+ # # detone and tone are provided as aliases.
1733
+ # def detone(t, color=None, hue=None, lightness=None, method="relative"):
1734
+ # return chromatize(t, color=color, hue=hue, lightness=lightness, method=method)
1735
+ #
1736
+ #
1737
+ # def tone(t, color=None, hue=None, lightness=None, method="relative"):
1738
+ # return dechromatize(t, color=color, hue=hue, lightness=lightness, method=method)
1739
+ #
1740
+ #
1741
+ # # Lerps the lightness for the given color. Negative t darkens if the method is
1742
+ # # relative
1743
+ # def lighten(t, color=None, hue=None, chroma=None, method="relative"):
1744
+ # assert (color is None) ^ (hue is None), (
1745
+ # "Exactly one of color or hue must be provided!"
1746
+ # )
1747
+ #
1748
+ # if hue is not None:
1749
+ # if not isinstance(hue, (float, int)):
1750
+ # raise ValueError(f"Expected number, received {type(chroma)}!")
1751
+ #
1752
+ # assert chroma is not None, "Lightness must be specified with explicit hue!"
1753
+ # if not isinstance(chroma, (float, int)):
1754
+ # raise ValueError(f"Expected number, received {type(chroma)}!")
1755
+ #
1756
+ # assert method == "absolute", (
1757
+ # "Explicit hue can only be specified with method 'relative'!"
1758
+ # )
1759
+ # # Generate a color object with the given parameters
1760
+ # color = OKLCH(0.5, chroma, hue)
1761
+ #
1762
+ # else:
1763
+ # color = __get_OKLCH_if_color(color)
1764
+ #
1765
+ # # Find the min and max lightness
1766
+ # bounds = _find_lightness_bounds(color)
1767
+ #
1768
+ # if method == "relative":
1769
+ # if t < -1 or t > 1:
1770
+ # raise ValueError("t should be in the range [-1,1] for method 'relative'!")
1771
+ #
1772
+ # # Interpolate between current l and maximum
1773
+ # if t >= 0:
1774
+ # L = _lerp(t, color.l, bounds[1])
1775
+ #
1776
+ # # Negative t means we interpolate towards the minimum instead
1777
+ # else:
1778
+ # L = _lerp(-t, color.l, bounds[0])
1779
+ #
1780
+ # elif method == "absolute":
1781
+ # if t < 0 or t > 1:
1782
+ # raise ValueError("t should be in the range [0,1] for method 'absolute'!")
1783
+ #
1784
+ # # In absolute mode we interpolate between minimum and max
1785
+ # L = _lerp(t, bounds[0], bounds[1])
1786
+ #
1787
+ # else:
1788
+ # raise ValueError(f"""Unknown method: '{method}'!
1789
+ # Valid methods are 'relative' and 'absolute'.""")
1790
+ #
1791
+ # ret = OKLCH(L, color.c, color.h)
1792
+ # # Make sure that the color is in-gamut
1793
+ # if ret.is_in_gamut():
1794
+ # return ret
1795
+ # else:
1796
+ # # Clip it into gamut
1797
+ # return _find_gamut_intersection(L, color.c, hue=color.h)
1798
+ #
1799
+ #
1800
+ # # Simply reverses the direction of chromatize
1801
+ # def darken(t, color=None, hue=None, chroma=None, method="relative"):
1802
+ # # Negate t and lighten
1803
+ # if method == "relative":
1804
+ # return lighten(-t, color=color, hue=hue, chroma=chroma)
1805
+ #
1806
+ # # Reverse t and lighten
1807
+ # elif method == "absolute":
1808
+ # return lighten(1 - t, color=color, hue=hue, chroma=chroma, method="absolute")
1809
+ #
1810
+ # else:
1811
+ # raise ValueError(f"""Unknown method: '{method}'!
1812
+ # Valid methods are 'relative' and 'absolute'.""")
1813
+ #
1814
+ #
1815
+ # # Linearly interpolate between two colors
1816
+ # # Hue path is determined by method parameter
1817
+ # def interpolate(t, color1, color2, method="shortest"):
1818
+ # color1 = __get_OKLCH_if_color(color1)
1819
+ # color2 = __get_OKLCH_if_color(color2)
1820
+ #
1821
+ # # Get the lerped parameters
1822
+ # li = _lerp(t, color1.l, color2.l)
1823
+ # ch = _lerp(t, color1.c, color2.c)
1824
+ #
1825
+ # # Hue path is determined by method
1826
+ # if method == "shortest":
1827
+ # # Ensures hue takes the shortest path
1828
+ # if color2.h - color1.h > 180:
1829
+ # h = _lerp(t, color1.h + 360, color2.h)
1830
+ # elif color2.h - color1.h < -180:
1831
+ # h = _lerp(t, color1.h, color2.h + 360)
1832
+ # else:
1833
+ # h = _lerp(t, color1.h, color2.h)
1834
+ # elif method == "longest":
1835
+ # # Ensures hue takes the longest path
1836
+ # if 0 < color2.h - color1.h < 180:
1837
+ # h = _lerp(t, color1.h + 360, color2.h)
1838
+ # elif -180 < color2.h - color1.h <= 0:
1839
+ # h = _lerp(t, color1.h, color2.h + 360)
1840
+ # else:
1841
+ # h = _lerp(t, color1.h, color2.h)
1842
+ # elif method == "increasing":
1843
+ # # Ensures hue is strictly increasing
1844
+ # if color2.h < color1.h:
1845
+ # h = _lerp(t, color1.h, color2.h + 360)
1846
+ # else:
1847
+ # h = _lerp(t, color1.h, color2.h)
1848
+ # elif method == "decreasing":
1849
+ # # Ensures hue is strictly decreasing
1850
+ # if color1.h < color2.h:
1851
+ # h = _lerp(t, color1.h + 360, color2.h)
1852
+ # else:
1853
+ # h = _lerp(t, color1.h, color2.h)
1854
+ # elif method == "use_OKLAB":
1855
+ # color1 = color1.to_OKLAB()
1856
+ # color2 = color2.to_OKLAB()
1857
+ #
1858
+ # a = _lerp(t, color1.a, color2.a)
1859
+ # b = _lerp(t, color1.b, color2.b)
1860
+ # result = OKLAB(li, a, b).to_OKLCH()
1861
+ #
1862
+ # ch = result.c
1863
+ # h = result.h
1864
+ # else:
1865
+ # raise ValueError(f"""Unknown method '{method}'! Valid methods are:
1866
+ # 'shortest', 'longest', 'increasing', 'decreasing', and 'use_OKLAB'.""")
1867
+ #
1868
+ # ret = OKLCH(li, ch, h)
1869
+ # # Make sure that the color is in-gamut
1870
+ # if ret.is_in_gamut():
1871
+ # return ret
1872
+ # else:
1873
+ # # Clip it into gamut
1874
+ # return gamut_clip_preserve_lightness(ret)
1875
+ #
1876
+ #
1877
+ # # Gamut clipping:
1878
+ # def gamut_clip_hue_dependent(color):
1879
+ # _color = __get_OKLCH_if_color(color)
1880
+ # if color.is_in_gamut():
1881
+ # return color
1882
+ #
1883
+ # return _find_gamut_intersection(_color.l, _color.c, color=_color)
1884
+ #
1885
+ #
1886
+ # def gamut_clip_hue_independent(color):
1887
+ # _color = __get_OKLCH_if_color(color)
1888
+ # if color.is_in_gamut():
1889
+ # return color
1890
+ #
1891
+ # return _find_gamut_intersection(
1892
+ # _color.l, _color.c, color=_color, method="hue_dependent"
1893
+ # )
1894
+ #
1895
+ #
1896
+ # def gamut_clip_preserve_lightness(color):
1897
+ # _color = __get_OKLCH_if_color(color)
1898
+ # if color.is_in_gamut():
1899
+ # return color
1900
+ #
1901
+ # return _find_gamut_intersection(
1902
+ # _color.l, _color.c, color=_color, method="preserve_lightness"
1903
+ # )