Skip to content

Commit ef2f167

Browse files
committed
Prototype of checking if color is in the visible spectrum
Resolves #333
1 parent 152d285 commit ef2f167

20 files changed

+1107
-116
lines changed

coloraide/color.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from . import util
2121
from . import algebra as alg
2222
from .channels import ANGLE_DEG, ANGLE_RAD, ANGLE_GRAD, ANGLE_TURN, ANGLE_NULL
23-
from .deprecate import warn_deprecated
23+
from .deprecate import warn_deprecated, deprecated
2424
from itertools import zip_longest as zipl
2525
from .css import parse
2626
from .types import VectorLike, Vector, ColorInput
@@ -83,7 +83,7 @@
8383
from .temperature.ohno_2013 import Ohno2013
8484
from .temperature.robertson_1968 import Robertson1968
8585
from .types import Plugin
86-
from typing import Iterator, overload, Sequence, Iterable, Any, Callable, Mapping
86+
from typing import Iterator, overload, Sequence, Iterable, Any, Callable, Mapping, cast
8787
if (3, 11) <= sys.version_info:
8888
from typing import Self
8989
else:
@@ -375,6 +375,8 @@ def register(
375375
mapping = cls.CS_MAP
376376
reset_convert_cache = True
377377
p = i
378+
if p.NAME in gamut.SPECIAL_GAMUTS:
379+
raise ValueError(f"Color space name '{p.NAME}' conflicts with the an internal, special gamut")
378380
elif isinstance(i, DeltaE):
379381
mapping = cls.DE_MAP
380382
p = i
@@ -1001,6 +1003,10 @@ def fit(
10011003
if method == 'clip':
10021004
return self.clip(space)
10031005

1006+
# Handle special gamut requests
1007+
if space in gamut.SPECIAL_GAMUTS:
1008+
return cast(Self, gamut.SPECIAL_GAMUTS[space]['fit'](self))
1009+
10041010
# If within gamut, just normalize hue range by calling clip.
10051011
if self.in_gamut(space, tolerance=0):
10061012
self.clip(space)
@@ -1028,6 +1034,10 @@ def in_gamut(self, space: str | None = None, *, tolerance: float = util.DEF_FIT_
10281034
if space is None:
10291035
space = self.space()
10301036

1037+
# Handle special gamut requests
1038+
if space in gamut.SPECIAL_GAMUTS:
1039+
return cast(bool, gamut.SPECIAL_GAMUTS[space]['check'](self, tolerance=tolerance))
1040+
10311041
# Check if gamut is in the provided space
10321042
c = self.convert(space, norm=False) if space is not None and space != self.space() else self
10331043

@@ -1042,12 +1052,14 @@ def in_gamut(self, space: str | None = None, *, tolerance: float = util.DEF_FIT_
10421052

10431053
return gamut.verify(c, tolerance)
10441054

1045-
def in_pointer_gamut(self, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool:
1055+
@deprecated("`color.in_pointer_gamut()` has been deprecated in favor of using `color.in_gamut('pointer-gamut')`")
1056+
def in_pointer_gamut(self, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool: # pragma: no cover
10461057
"""Check if in pointer gamut."""
10471058

10481059
return gamut.pointer.in_pointer_gamut(self, tolerance)
10491060

1050-
def fit_pointer_gamut(self) -> Self:
1061+
@deprecated("`color.fit_pointer_gamut()` has been deprecated in favor of using `color.fit('pointer-gamut')`")
1062+
def fit_pointer_gamut(self) -> Self: # pragma: no cover
10511063
"""Check if in pointer gamut."""
10521064

10531065
return gamut.pointer.fit_pointer_gamut(self)

coloraide/gamut/__init__.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,26 @@
33
import math
44
from ..channels import FLG_ANGLE
55
from abc import ABCMeta, abstractmethod
6-
from ..types import Plugin
7-
from typing import Any, TYPE_CHECKING
6+
from ..types import Plugin, AnyColor
7+
from typing import Any, TYPE_CHECKING, Callable
88
from . import pointer
9+
from . import visible_spectrum
910

1011
if TYPE_CHECKING: #pragma: no cover
1112
from ..color import Color
1213

13-
__all__ = ('clip_channels', 'verify', 'Fit', 'pointer')
14+
__all__ = ('clip_channels', 'verify', 'Fit', 'pointer', 'visible_spectrum')
15+
16+
SPECIAL_GAMUTS = {
17+
'pointer-gamut': {
18+
'check': pointer.in_pointer_gamut,
19+
'fit': pointer.fit_pointer_gamut
20+
},
21+
'macadam-limits': {
22+
'check': visible_spectrum.in_macadam_limits,
23+
'fit': visible_spectrum.fit_macadam_limits
24+
}
25+
} # type: dict[str, dict[str, Callable[..., Any]]]
1426

1527

1628
def clip_channels(color: Color, nans: bool = True) -> bool:

coloraide/gamut/pointer.py

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121
WHITE_POINT_SC = tuple(util.xyz_to_xyY(XYZ_W)[:-1]) # type: VectorLike
2222
# Rows: hue 0 - 360 at steps of 10
2323
# Columns: lightness 15 - 90 at steps of 5
24-
LCH_L = [*range(15, 91, 5)]
25-
LCH_H = [*range(0, 361, 10)]
26-
GAMUT = [
24+
LIGHTNESS = [*range(15, 91, 5)]
25+
HUE = [*range(0, 361, 10)]
26+
LUT = [
2727
[10, 30, 43, 56, 68, 77, 79, 77, 72, 65, 57, 50, 40, 30, 19, 8],
2828
[15, 30, 45, 56, 64, 70, 73, 73, 71, 65, 57, 48, 39, 30, 18, 7],
2929
[14, 34, 49, 61, 69, 74, 76, 76, 74, 68, 61, 51, 40, 30, 19, 9],
@@ -90,19 +90,19 @@ def closest_lightness(l: float) -> tuple[int, float]:
9090
"""Calculate the two closest lightness values and return the first index and interpolation factor."""
9191

9292
# Handle too low lightness inside tolerance
93-
if l <= LCH_L[0]:
93+
if l <= LIGHTNESS[0]:
9494
li = 0
9595
lf = 0.0
9696

9797
# Handle too high lightness inside tolerance
98-
elif l >= LCH_L[-1]:
99-
li = len(LCH_L) - 2
98+
elif l >= LIGHTNESS[-1]:
99+
li = len(LIGHTNESS) - 2
100100
lf = 1.0
101101

102-
# Handle lightness with gamut
102+
# Handle lightness within gamut
103103
else:
104-
li = bisect.bisect(LCH_L, l) - 1
105-
l1, l2 = LCH_L[li:li + 2]
104+
li = bisect.bisect(LIGHTNESS, l) - 1
105+
l1, l2 = LIGHTNESS[li:li + 2]
106106
lf = 1 - (l2 - l) / (l2 - l1)
107107

108108
return li, lf
@@ -111,9 +111,21 @@ def closest_lightness(l: float) -> tuple[int, float]:
111111
def closest_hue(h: float) -> tuple[int, float]:
112112
"""Calculate the two closest hues and return the first index and interpolation factor."""
113113

114-
hi = bisect.bisect(LCH_H, h) - 1
115-
h1, h2 = LCH_H[hi:hi + 2]
116-
hf = 1 - (h2 - h) / (h2 - h1)
114+
# Handle hue at the start
115+
if h == HUE[0]: # pragma: no cover
116+
hi = 0
117+
hf = 0.0
118+
119+
# Handle hue at the end
120+
elif h == HUE[-1]: # pragma: no cover
121+
hi = len(HUE) - 2
122+
hf = 1.0
123+
124+
# Handle all other hues
125+
else:
126+
hi = bisect.bisect(HUE, h) - 1
127+
h1, h2 = HUE[hi:hi + 2]
128+
hf = 1 - (h2 - h) / (h2 - h1)
117129

118130
return hi, hf
119131

@@ -128,10 +140,10 @@ def get_chroma_limit(l: float, h: float) -> float:
128140
hi, hf = closest_hue(h)
129141

130142
# Interpolate the chroma limit by interpolating chroma values for the closest lightness values and hues.
131-
if hi == len(LCH_H) - 1:
132-
row1, row2 = GAMUT[-1], GAMUT[0]
143+
if hi == len(HUE) - 1:
144+
row1, row2 = LUT[-1], LUT[0]
133145
else:
134-
row1, row2 = GAMUT[hi:hi + 2]
146+
row1, row2 = LUT[hi:hi + 2]
135147
return alg.lerp(alg.lerp(row1[li], row1[li + 1], lf), alg.lerp(row2[li], row2[li + 1], lf), hf)
136148

137149

@@ -142,8 +154,8 @@ def fit_pointer_gamut(color: AnyColor) -> AnyColor:
142154
l, c, h = to_lch_sc(color)
143155

144156
# Clamp lightness
145-
new_l = max(LCH_L[0], l)
146-
new_l = min(LCH_L[-1], new_l)
157+
new_l = max(LIGHTNESS[0], l)
158+
new_l = min(LIGHTNESS[-1], new_l)
147159

148160
new_c = min(c, get_chroma_limit(l, h))
149161

@@ -167,7 +179,7 @@ def in_pointer_gamut(color: Color, tolerance: float) -> bool:
167179
l, c, h = to_lch_sc(color)
168180

169181
# If lightness exceeds the acceptable range, then we are not in gamut
170-
if (l < (LCH_L[0] - tolerance)) or (l > (LCH_L[-1] + tolerance)):
182+
if (l < (LIGHTNESS[0] - tolerance)) or (l > (LIGHTNESS[-1] + tolerance)):
171183
return False
172184

173185
# Test that the color does not exceed the max chroma
@@ -186,11 +198,11 @@ def pointer_gamut_boundary(lightness: float | None = None) -> Matrix:
186198
# For each hue, find the lightness/chroma point that is furthest away from the white point.
187199
if lightness is None:
188200
max_gamut = [] # type: Matrix
189-
for i, h in enumerate(LCH_H[:-1]):
201+
for i, h in enumerate(HUE[:-1]):
190202
max_dxy = 0.0
191203
max_xyy = [0.0, 0.0, 0.0]
192-
for j, l in enumerate(LCH_L):
193-
xyy = lch_sc_to_xyY([l, GAMUT[i][j], h])
204+
for j, l in enumerate(LIGHTNESS):
205+
xyy = lch_sc_to_xyY([l, LUT[i][j], h])
194206
dxy = math.sqrt((WHITE_POINT_SC[0] - xyy[0]) ** 2 + (WHITE_POINT_SC[1] - xyy[1]) ** 2)
195207
if dxy > max_dxy:
196208
max_dxy = dxy
@@ -200,11 +212,11 @@ def pointer_gamut_boundary(lightness: float | None = None) -> Matrix:
200212

201213
# Pointer gamut boundary at a given lightness
202214
# Return all the points for a given lightness
203-
elif LCH_L[0] <= lightness <= LCH_L[-1]:
215+
elif LIGHTNESS[0] <= lightness <= LIGHTNESS[-1]:
204216
li, lf = closest_lightness(lightness)
205-
chroma = [alg.lerp(row[li], row[li + 1], lf) for row in GAMUT[:-1]]
206-
return [lch_sc_to_xyY([lightness, c, h]) for c, h in zip(chroma, LCH_H)]
217+
chroma = [alg.lerp(row[li], row[li + 1], lf) for row in LUT[:-1]]
218+
return [lch_sc_to_xyY([lightness, c, h]) for c, h in zip(chroma, HUE)]
207219

208220
# Lightness exceeds threshold
209221
else:
210-
raise ValueError(f'Lightness must be between {LCH_L[0]} and {LCH_L[-1]}, but was {lightness}')
222+
raise ValueError(f'Lightness must be between {LIGHTNESS[0]} and {LIGHTNESS[-1]}, but was {lightness}')

0 commit comments

Comments
 (0)