Skip to content

Commit c56f60e

Browse files
authored
Create colors from wavelengths and estimate dominant wavelengths (#504)
1 parent 1f03bb3 commit c56f60e

File tree

16 files changed

+628
-18
lines changed

16 files changed

+628
-18
lines changed

coloraide/algebra.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,6 +1182,61 @@ def pprint(value: float | ArrayLike) -> None:
11821182
print(pretty(value))
11831183

11841184

1185+
def ray_line_intersect(
1186+
a1: VectorLike,
1187+
a2: VectorLike,
1188+
b1: VectorLike,
1189+
b2: VectorLike,
1190+
abs_tol: float = ATOL
1191+
) -> Vector | None:
1192+
"""Find the intersection of a ray and a line."""
1193+
1194+
da = [a - b for a, b in zip(a2, a1)]
1195+
db = [a - b for a, b in zip(b2, b1)]
1196+
dp = [a - b for a, b in zip(a1, b1)]
1197+
dap = [-da[1], da[0]]
1198+
denom = dot(dap, db, dims=D1)
1199+
# Perpendicular cases
1200+
if abs(denom) < abs_tol: # pragma: no cover
1201+
return None
1202+
t = dot(dap, dp, dims=D1) / denom
1203+
# Intersect
1204+
i = add(b1, multiply(t, db, dims=SC_D1), dims=D1)
1205+
# Check if intersection is within bounds
1206+
if 0 <= t <= 1:
1207+
return i
1208+
return None # pragma: no cover
1209+
1210+
1211+
def line_intersect(
1212+
a1: VectorLike,
1213+
a2: VectorLike,
1214+
b1: VectorLike,
1215+
b2: VectorLike,
1216+
abs_tol: float = ATOL
1217+
) -> Vector | None: # pragma: no cover
1218+
"""Find the intersection of of lines."""
1219+
1220+
da = [a - b for a, b in zip(a2, a1)]
1221+
db = [a - b for a, b in zip(b2, b1)]
1222+
dp = [a - b for a, b in zip(a1, b1)]
1223+
dap = [-da[1], da[0]]
1224+
denom = dot(dap, db, dims=D1)
1225+
# Perpendicular cases
1226+
if abs(denom) < abs_tol: # pragma: no cover
1227+
return None
1228+
t = dot(dap, dp, dims=D1) / denom
1229+
# Intersect
1230+
i = add(b1, multiply(t, db, dims=SC_D1), dims=D1)
1231+
# Check if intersection is within bounds
1232+
if (
1233+
0 <= t <= 1 and
1234+
0 <= divide(dot(subtract(i, a1, dims=D1), da, dims=D1), dot(da, da, dims=D1), dims=SC) <= 1
1235+
):
1236+
return i
1237+
return None
1238+
1239+
11851240
def all(a: float | ArrayLike) -> bool: # noqa: A001
11861241
"""Return true if all elements are "true"."""
11871242

coloraide/cmfs.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ def __init__(self, cmfs: dict[float, Vector], /, interpolator: str = 'cubic', **
1313
"""Initialize and capture attributes of CMF."""
1414

1515
keys = list(cmfs.keys())
16-
self.spline = alg.interpolate(list(cmfs.values()), method='sprague')
1716
self.start = int(keys[0])
1817
self.end = int(keys[-1])
1918
self.step = round((self.end - self.start) / (len(keys) - 1))
19+
self.spline = alg.interpolate(list(cmfs.values()), method='sprague', domain=[self.start, self.end])
2020
super().__init__(cmfs, **kwargs)
2121

2222
def __getitem__(self, key: float) -> Vector: # pragma: no cover
@@ -25,9 +25,7 @@ def __getitem__(self, key: float) -> Vector: # pragma: no cover
2525
value = super().get(key)
2626
if value is not None:
2727
return value[:]
28-
29-
factor = (key - self.start) / (self.end - self.start)
30-
return self.spline(factor)
28+
return self.spline(key)
3129

3230
def xy(self, key: float) -> Vector: # pragma: no cover
3331
"""Return CMFs data as XY coordinates."""

coloraide/color.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from . import temperature
2020
from . import util
2121
from . import algebra as alg
22+
from . import spectrum
2223
from .channels import ANGLE_DEG, ANGLE_RAD, ANGLE_GRAD, ANGLE_TURN, ANGLE_NULL
2324
from .deprecate import warn_deprecated, deprecated
2425
from itertools import zip_longest as zipl
@@ -1342,6 +1343,51 @@ def contrast(self, color: ColorInput, method: str | None = None) -> float:
13421343
color = self._handle_color_input(color)
13431344
return contrast.contrast(method, self, color)
13441345

1346+
def wavelength(
1347+
self,
1348+
*,
1349+
white: VectorLike | None = None,
1350+
complementary: bool = False
1351+
) -> tuple[float, Vector, Vector]:
1352+
"""Get the dominant wavelength."""
1353+
1354+
return spectrum.closest_wavelength(
1355+
self.xy(),
1356+
white or util.xyz_to_xyY(self.white())[:-1],
1357+
reverse=complementary
1358+
)
1359+
1360+
@classmethod
1361+
def from_wavelength(
1362+
cls,
1363+
space: str,
1364+
wavelength: float,
1365+
*,
1366+
scale: bool = True,
1367+
scale_space: str | None = None
1368+
) -> Self:
1369+
"""Create a color from a wavelength."""
1370+
1371+
xyz = spectrum.wavelength_to_color(wavelength)
1372+
color = cls('xyz-d65', xyz)
1373+
1374+
if scale_space is None:
1375+
scale_space = 'srgb-linear'
1376+
1377+
# Bypass chromatic adaptation to ensure we get the appropriate wavelength into the given white point.
1378+
# If a scaling is applied, we skip to get to this space, but subsequent conversions will be adapted.
1379+
if scale and isinstance(cls.CS_MAP[scale_space], RGBish):
1380+
color._space, color._coords[:-1] = convert.convert(color, scale_space, skip_adaptation=True)
1381+
color[:-1] = util.rgb_scale(color.coords())
1382+
# Convert to targeted color space
1383+
color.convert(space, in_place=True)
1384+
else:
1385+
color._space, color._coords[:-1] = convert.convert(color, space, skip_adaptation=True)
1386+
# Unlikely to get an achromatic, but just in case
1387+
if color._space.is_polar() and color.is_achromatic(): # pragma: no cover
1388+
color[color._space.hue_index()] = math.nan # type: ignore[attr-defined]
1389+
return color
1390+
13451391
@overload
13461392
def get(self,
13471393
name: str,

coloraide/convert.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def get_convert_chain(
119119
return chain
120120

121121

122-
def convert(color: Color, space: str) -> tuple[Space, Vector]:
122+
def convert(color: Color, space: str, skip_adaptation: bool = False) -> tuple[Space, Vector]:
123123
"""Convert the color coordinates to the specified space."""
124124

125125
# Grab the convert for the current space to the desired space
@@ -133,15 +133,15 @@ def convert(color: Color, space: str) -> tuple[Space, Vector]:
133133
# Perform chromatic adaption if needed (a conversion to or from XYZ D65).
134134
last = color._space
135135
for a, b, direction, adapt in chain:
136-
if direction and adapt:
136+
if direction and adapt and not skip_adaptation:
137137
coords = color.chromatic_adaptation(
138138
a.WHITE,
139139
b.WHITE,
140140
coords
141141
)
142142

143143
coords = b.from_base(coords) if direction else a.to_base(coords)
144-
if not direction and adapt:
144+
if not direction and adapt and not skip_adaptation:
145145
coords = color.chromatic_adaptation(
146146
a.WHITE,
147147
b.WHITE,

coloraide/spectrum.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""
2+
Handle spectral related things.
3+
4+
- Calculations colors from/to wavelengths.
5+
"""
6+
from __future__ import annotations
7+
import math
8+
from . import algebra as alg
9+
from . import util
10+
from .types import Vector, VectorLike
11+
from . import cat
12+
from . import cmfs
13+
14+
WHITE = cat.WHITES['2deg']['E']
15+
LOCUS_START = 360
16+
LOCUS_END = 780
17+
LOCUS_STEP = 1
18+
19+
20+
def xy_to_angle(xy: VectorLike, white: VectorLike, offset: float = 0.0, invert: bool = False) -> float:
21+
"""
22+
Translate xy to an angle with white being the origin.
23+
24+
If offset is provided, make the angle relative to the offset angle.
25+
26+
If invert is requested, we want to rotate the xy point 180 degrees.
27+
"""
28+
29+
norm = alg.subtract(xy, white, dims=alg.D1)
30+
if invert:
31+
norm = alg.multiply(norm, -1, dims=alg.D1_SC)
32+
if offset:
33+
angle = (alg.rect_to_polar(*norm)[1] - offset) % 360
34+
if angle < 1e-12:
35+
angle = 360.0
36+
else:
37+
angle = alg.rect_to_polar(*norm)[1]
38+
return angle
39+
40+
41+
def closest_wavelength(
42+
xy: VectorLike,
43+
white: VectorLike = WHITE,
44+
reverse: bool = False,
45+
closest: bool = True
46+
) -> tuple[float, Vector, Vector]:
47+
"""
48+
Get the closest dominant wavelength.
49+
50+
Both intersections are returned, even if the other is on the line of purple.
51+
The first point is always in the dominant direction. If the dominant cannot
52+
be found, the complementary will be used and indicated with a negative sign.
53+
54+
If `reverse` is set, then the complementary wavelength is returned instead,
55+
with the points arranged favoring the complementary wavelength. If it cannot
56+
be found, the dominant wavelength is returned with a negative sign.
57+
58+
If `closet` is set, wavelengths are rounded to the closest.
59+
"""
60+
61+
w1 = w2 = math.nan
62+
dominant = [math.nan, math.nan]
63+
complementary = [math.nan, math.nan]
64+
65+
# Achromatic, no wavelength
66+
if all(abs(a - b) < 1e-12 for a, b in zip(xy, white)):
67+
return w1, dominant, complementary
68+
69+
# Look for first intersection of the line drawn through the white point
70+
# and the current color with the spectral locus. Check the dominant and
71+
# complementary, but return as soon as we have the dominant. If no dominant
72+
# is found, we'll use the complementary.
73+
locus = [util.xyz_to_xyY(cmfs.CIE_1931_2DEG[r], white)[:-1] for r in range(LOCUS_START, LOCUS_END + 1, LOCUS_STEP)]
74+
start = xy_to_angle(locus[0], white)
75+
current = xy_to_angle(xy, white, start)
76+
invert = xy_to_angle(xy, white, start, invert=True)
77+
found = [False, False]
78+
for i in range(1, len(locus) - 2):
79+
# Get the next locus point angle
80+
a_next = xy_to_angle(locus[i], white, start)
81+
82+
# Check if our angle is greater than the current locus point's angle
83+
for j in range(0, 2):
84+
85+
# If has already been found, skip
86+
target = invert if j else current
87+
if a_next > target or found[j]:
88+
continue
89+
90+
# Previous index
91+
i0 = i - 1
92+
93+
# Get the intersection
94+
if target == a_next:
95+
intersect = locus[i] # type: Vector | None
96+
elif target == xy_to_angle(locus[i0], white, start):
97+
intersect = locus[i0]
98+
else:
99+
intersect = alg.ray_line_intersect(white, xy, locus[i0], locus[i])
100+
if intersect is None: # pragma: no cover
101+
continue
102+
103+
# Use the intersection to estimate the interpolation factor for the wavelength,
104+
# and then get the interpolated value via Sprague interpolation.
105+
i2 = 0 if abs(locus[i0][0] - locus[i][0]) > abs(locus[i0][1] - locus[i][1]) else 1
106+
f = alg.ilerp(locus[i0][i2], locus[i][i2], intersect[i2])
107+
w = alg.lerp(LOCUS_START + i0, LOCUS_START + i, f)
108+
intersect = util.xyz_to_xyY(cmfs.CIE_1931_2DEG[w], white)[:-1]
109+
110+
if j == 0:
111+
dominant = intersect
112+
w1 = w
113+
found[j] = True
114+
if not reverse:
115+
break
116+
else:
117+
complementary = intersect
118+
w2 = w
119+
found[j] = True
120+
if reverse:
121+
break
122+
123+
if found[reverse]:
124+
break
125+
126+
# Unlikely catastrophic failure
127+
if not any(found): # pragma: no cover
128+
return w1, dominant, complementary
129+
130+
# Swap dominant and complementary if we are looking for complementary
131+
if reverse:
132+
dominant, complementary = complementary, dominant
133+
w1, w2 = w2, w1
134+
135+
# If dominant isn't found, it is on the line of purples; use complementary instead
136+
if not found[reverse]:
137+
pt = alg.ray_line_intersect(white, xy, locus[0], locus[-1])
138+
# Shouldn't happen, but just in case
139+
if pt is not None: # pragma: no cover
140+
dominant = pt
141+
w1 = -alg.round_half_up(w2) if closest else -w2
142+
else:
143+
complementary = dominant
144+
if closest:
145+
w1 = alg.round_half_up(w1)
146+
147+
return w1, dominant, complementary
148+
149+
150+
def wavelength_to_color(wavelength: float) -> Vector:
151+
"""Return the XYZ value for the specified wavelength."""
152+
153+
if wavelength < LOCUS_START or wavelength > LOCUS_END:
154+
raise ValueError(f'{wavelength}nm exceeds the range of {LOCUS_START}nm - {LOCUS_END}nm')
155+
156+
# Wavelength is within the CMFs
157+
return cmfs.CIE_1931_2DEG[wavelength]

docs/src/dictionary/en-custom.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ undersaturated
395395
uniformities
396396
unintuitive
397397
unsaturate
398+
unscaled
398399
uv
399400
uvY
400401
validator

docs/src/markdown/.snippets/abbr.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@
1919
*[SDR]: Standard Dynamic Range
2020
*[SVG]: Scalable Vector Graphics
2121
*[UCS]: Uniform Chromaticity Space
22-
*[WCAG]: Web Content Accessibility Guidelines
22+
*[WCAG]: Web Content Accessibility Guidelines
125 KB
Loading
125 KB
Loading

docs/src/markdown/temperature.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,7 @@ out to even this limit.
9090
## Out of Gamut Temperatures
9191

9292
It should be noted that `blackbody()` normalizes/scales the returned colors by default as the colors are often much too
93-
bright initially, all having a max luminance. This scaling is usually done under a linear RGB color space, Linear
94-
Rec2020 being the default as it encompasses the entire curve.
93+
bright initially, all having a max luminance. This scaling is done is linear sRGB.
9594

9695
Keep in mind that if the color is not in the display gamut it will need to be gamut mapped, and the gamut mapped value
9796
will not exhibit the same temperature. How far off it is will be depends on the disparity of the gamut sizes and how the

0 commit comments

Comments
 (0)