Skip to content

Commit 62f5a90

Browse files
authored
Overhaul color scaling and add a gamut mapping method that performs it (#505)
- New gamut mapping method called scale added that scales a color into gamut while preserving lower end luminance and relative, dominant wavelength, with options to clip instead of trying to preserve the dominant wavelength, and an option to abandon luminance preserving. - blackbody(), from_wavelength(), and chromaticity() all use new scaling logic to perform color scale fitting. - Refactor temperature plugins to let the Color object scale the colors and focus on just calculating the chromaticity points. - Extend wavelength functions' allowed wavelength range.
1 parent cd2d240 commit 62f5a90

30 files changed

+807
-406
lines changed

coloraide/__meta__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,5 +204,5 @@ def parse_version(ver: str) -> Version:
204204
return Version(major, minor, micro, release, pre, post, dev)
205205

206206

207-
__version_info__ = Version(8, 3, 0, "final")
207+
__version_info__ = Version(8, 4, 0, "final")
208208
__version__ = __version_info__._get_canonical()

coloraide/algebra.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,11 +1200,10 @@ def ray_line_intersect(
12001200
if abs(denom) < abs_tol: # pragma: no cover
12011201
return None
12021202
t = dot(dap, dp, dims=D1) / denom
1203-
# Intersect
1204-
i = add(b1, multiply(t, db, dims=SC_D1), dims=D1)
12051203
# Check if intersection is within bounds
12061204
if 0 <= t <= 1:
1207-
return i
1205+
# Intersect
1206+
return add(b1, multiply(t, db, dims=SC_D1), dims=D1)
12081207
return None # pragma: no cover
12091208

12101209

coloraide/color.py

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from itertools import zip_longest as zipl
2626
from .css import parse
2727
from .types import VectorLike, Vector, ColorInput
28-
from .spaces import Space, RGBish
28+
from .spaces import Space
2929
from .spaces.hsv import HSV
3030
from .spaces.srgb.css import sRGB
3131
from .spaces.srgb_linear import sRGBLinear
@@ -69,6 +69,7 @@
6969
from .gamut.fit_lch_chroma import LChChroma
7070
from .gamut.fit_oklch_chroma import OkLChChroma
7171
from .gamut.fit_raytrace import RayTrace
72+
from .gamut.fit_scale import Scale
7273
from .cat import CAT, Bradford
7374
from .filters import Filter
7475
from .filters.w3c_filter_effects import Sepia, Brightness, Contrast, Saturate, Opacity, HueRotate, Grayscale, Invert
@@ -523,9 +524,11 @@ def blackbody(
523524
temp: float,
524525
duv: float = 0.0,
525526
*,
527+
method: str | None = None,
526528
scale: bool = True,
527529
scale_space: str | None = None,
528-
method: str | None = None,
530+
max_saturation: bool = True,
531+
clip_negative: bool = False,
529532
**kwargs: Any
530533
) -> Self:
531534
"""
@@ -542,8 +545,16 @@ def blackbody(
542545
"""
543546

544547
cct = temperature.cct(method, cls)
545-
color = cct.from_cct(cls, space, temp, duv, scale, scale_space, **kwargs)
546-
return color
548+
uv, name = cct.from_cct(temp, duv, **kwargs)
549+
return cls.chromaticity(
550+
space,
551+
uv,
552+
name,
553+
scale=scale,
554+
scale_space=scale_space,
555+
max_saturation=max_saturation,
556+
clip_negative=clip_negative
557+
)
547558

548559
def cct(self, *, method: str | None = None, **kwargs: Any) -> Vector:
549560
"""Get color temperature."""
@@ -829,9 +840,11 @@ def chromaticity(
829840
coords: VectorLike,
830841
cspace: str = 'uv-1976',
831842
*,
843+
white: VectorLike | None = None,
832844
scale: bool = False,
833845
scale_space: str | None = None,
834-
white: VectorLike | None = None
846+
max_saturation: bool = False,
847+
clip_negative: bool = False
835848
) -> Self:
836849
"""
837850
Create a color from chromaticity coordinates.
@@ -847,9 +860,6 @@ def chromaticity(
847860
limitations. Default linear RGB space is linear sRGB.
848861
"""
849862

850-
if scale_space is None:
851-
scale_space = 'srgb-linear'
852-
853863
# Use the white point of the target color space unless a white point is given.
854864
if white is None:
855865
white = cls.CS_MAP[space].WHITE
@@ -867,9 +877,13 @@ def chromaticity(
867877
)
868878

869879
# Normalize in the given RGB color space (ideally linear).
870-
if scale and isinstance(cls.CS_MAP[scale_space], RGBish):
871-
color.convert(scale_space, in_place=True)
872-
color[:-1] = util.rgb_scale(color.coords())
880+
if scale:
881+
gamut.scale_rgb(
882+
color,
883+
scale_space=scale_space if scale_space is not None else 'srgb-linear',
884+
max_saturation=max_saturation,
885+
clip_negative=clip_negative
886+
)
873887

874888
# Convert to targeted color space
875889
if space != color.space():
@@ -879,7 +893,8 @@ def chromaticity(
879893

880894
@classmethod
881895
def convert_chromaticity(
882-
cls, cspace1: str,
896+
cls,
897+
cspace1: str,
883898
cspace2: str,
884899
coords: VectorLike,
885900
*,
@@ -1353,7 +1368,7 @@ def wavelength(
13531368

13541369
return spectrum.closest_wavelength(
13551370
self.xy(),
1356-
white or util.xyz_to_xyY(self.white())[:-1],
1371+
white or self._space.WHITE,
13571372
reverse=complementary
13581373
)
13591374

@@ -1363,13 +1378,24 @@ def from_wavelength(
13631378
space: str,
13641379
wavelength: float,
13651380
*,
1381+
white: VectorLike | None = None,
13661382
scale: bool = True,
1367-
scale_space: str | None = None
1383+
scale_space: str | None = None,
1384+
max_saturation: bool = True,
1385+
clip_negative: bool = False
13681386
) -> Self:
13691387
"""Create a color from a wavelength."""
13701388

1371-
xyY = util.xyz_to_xyY(spectrum.wavelength_to_color(wavelength))
1372-
return cls.chromaticity(space, xyY, 'xy-1931', scale=scale, scale_space=scale_space)
1389+
return cls.chromaticity(
1390+
space,
1391+
util.xyz_to_xyY(spectrum.wavelength_to_color(wavelength)),
1392+
'xy-1931',
1393+
white=white,
1394+
scale=scale,
1395+
scale_space=scale_space,
1396+
max_saturation=max_saturation,
1397+
clip_negative=clip_negative
1398+
)
13731399

13741400
@overload
13751401
def get(self,
@@ -1618,6 +1644,7 @@ def alpha(
16181644
LChChroma(),
16191645
OkLChChroma(),
16201646
RayTrace(),
1647+
Scale(),
16211648

16221649
# Filters
16231650
Sepia(),

coloraide/gamut/__init__.py

Lines changed: 160 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
"""Gamut handling."""
22
from __future__ import annotations
33
import math
4-
from ..channels import FLG_ANGLE
54
from abc import ABCMeta, abstractmethod
6-
from ..types import Plugin
7-
from typing import Any, TYPE_CHECKING, Callable # noqa: F401
5+
from functools import lru_cache
86
from . import pointer
97
from . import visible_spectrum
8+
from .. import util
9+
from .. import algebra as alg
10+
from ..channels import FLG_ANGLE
11+
from ..types import Plugin, Vector, VectorLike
12+
from ..spaces import Prism, Luminant, Space, HSLish, HSVish, HWBish
13+
from ..spaces.hsl import hsl_to_srgb, srgb_to_hsl
14+
from ..spaces.hsv import hsv_to_srgb, srgb_to_hsv
15+
from ..spaces.hwb import hwb_to_hsv, hsv_to_hwb
16+
from ..spaces.srgb_linear import sRGBLinear
17+
from typing import Any, TYPE_CHECKING, Callable # noqa: F401
1018

1119
if TYPE_CHECKING: #pragma: no cover
1220
from ..color import Color
1321

14-
__all__ = ('clip_channels', 'verify', 'Fit', 'pointer', 'visible_spectrum')
22+
__all__ = ('clip_channels', 'verify', 'Fit', 'pointer', 'visible_spectrum', 'scale_rgb', 'coerce_to_rgb')
1523

1624
SPECIAL_GAMUTS = {
1725
'pointer-gamut': {
@@ -25,6 +33,154 @@
2533
} # type: dict[str, dict[str, Callable[..., Any]]]
2634

2735

36+
def hwb_to_srgb(coords: Vector) -> Vector: # pragma: no cover
37+
"""Convert HWB to sRGB."""
38+
39+
return hsv_to_srgb(hwb_to_hsv(coords))
40+
41+
42+
def srgb_to_hwb(coords: Vector) -> Vector: # pragma: no cover
43+
"""Convert sRGB to HWB."""
44+
45+
return hsv_to_hwb(srgb_to_hsv(coords))
46+
47+
48+
@lru_cache(maxsize=20, typed=True)
49+
def coerce_to_rgb(cs: Space) -> Space:
50+
"""
51+
Coerce an HSL, HSV, or HWB color space to RGB to allow us to ray trace the gamut.
52+
53+
It is rare to have a color space that is bound to an RGB gamut that does not exist as an RGB
54+
defined RGB space. HPLuv is one that is defined only as a cylindrical, HSL-like space. Okhsl
55+
and Okhsv are another whose gamut is meant to target sRGB, but it is very fuzzy and has sRGB
56+
colors not quite in gamut, and others that exceed the sRGB gamut.
57+
58+
For gamut mapping, RGB cylindrical spaces can be coerced into an RGB form using traditional
59+
HSL, HSV, or HWB approaches which is good enough.
60+
"""
61+
62+
if isinstance(cs, HSLish):
63+
to_ = hsl_to_srgb # type: Callable[[Vector], Vector]
64+
from_ = srgb_to_hsl # type: Callable[[Vector], Vector]
65+
elif isinstance(cs, HSVish):
66+
to_ = hsv_to_srgb
67+
from_ = srgb_to_hsv
68+
elif isinstance(cs, HWBish): # pragma: no cover
69+
to_ = hwb_to_srgb
70+
from_ = srgb_to_hwb
71+
else: # pragma: no cover
72+
raise ValueError(f'Cannot coerce {cs.NAME} to an RGB space.')
73+
74+
class RGB(sRGBLinear):
75+
"""Custom RGB class."""
76+
77+
NAME = f'-rgb-{cs.NAME}'
78+
BASE = cs.NAME
79+
GAMUT_CHECK = None
80+
CLIP_SPACE = None
81+
WHITE = cs.WHITE
82+
DYAMIC_RANGE = cs.DYNAMIC_RANGE
83+
INDEXES = cs.indexes()
84+
# Scale saturation and lightness (or HWB whiteness and blackness)
85+
SCALE_SAT = cs.channels[INDEXES[1]].high
86+
SCALE_LIGHT = cs.channels[INDEXES[2]].high
87+
88+
def to_base(self, coords: Vector) -> Vector:
89+
"""Convert from RGB to HSL."""
90+
91+
coords = from_(coords)
92+
if self.SCALE_SAT != 1:
93+
coords[1] *= self.SCALE_SAT
94+
if self.SCALE_LIGHT != 1:
95+
coords[2] *= self.SCALE_LIGHT
96+
ordered = [0.0, 0.0, 0.0]
97+
for e, c in enumerate(coords):
98+
ordered[self.INDEXES[e]] = c
99+
return ordered
100+
101+
def from_base(self, coords: Vector) -> Vector:
102+
"""Convert from HSL to RGB."""
103+
104+
coords = [coords[i] for i in self.INDEXES]
105+
if self.SCALE_SAT != 1:
106+
coords[1] /= self.SCALE_SAT
107+
if self.SCALE_LIGHT != 1:
108+
coords[2] /= self.SCALE_LIGHT
109+
coords = to_(coords)
110+
return coords
111+
112+
return RGB()
113+
114+
115+
def adjust_luminance(color: Color, Y: float, white: VectorLike) -> None:
116+
"""Adjust luminance of a color."""
117+
118+
with color.within('xyz-d65') as c:
119+
c.convert('xyz-d65', in_place=True)
120+
d65 = c._space.WHITE
121+
adapt = d65 != white
122+
xyz = c.chromatic_adaptation(d65, white, c[:-1]) if adapt else c[:-1]
123+
if xyz[1] > Y:
124+
xyz = util.xy_to_xyz(util.xyz_to_xyY(xyz, white)[:-1], Y)
125+
c[:-1] = c.chromatic_adaptation(white, d65, xyz) if adapt else xyz
126+
127+
128+
def scale_rgb(
129+
color: Color,
130+
*,
131+
scale_space: str,
132+
clip_negative: bool = False,
133+
max_saturation: bool = False
134+
) -> None:
135+
"""Apply color scaling."""
136+
137+
cs = color.CS_MAP[scale_space]
138+
orig_space = scale_space
139+
140+
# Requires an RGB-ish or Prism space, preferably a linear space.
141+
# Coerce RGB cylinders with no defined RGB space to RGB
142+
coerced = False
143+
if not isinstance(cs, Prism) or isinstance(cs, Luminant):
144+
coerced = True
145+
cs = coerce_to_rgb(cs)
146+
147+
# If there is a linear version of the RGB space, results will be better if we use that.
148+
maximum = cs.channels[0].high
149+
linear = cs.linear()
150+
if linear and linear in color.CS_MAP:
151+
subtractive = cs.SUBTRACTIVE
152+
cs = color.CS_MAP[linear]
153+
if subtractive != cs.SUBTRACTIVE:
154+
maximum = color.new(scale_space, [cs.CHANNELS[0].low] * 3).convert(linear, in_place=True)[0]
155+
else:
156+
maximum = color.new(scale_space, [maximum] * 3).convert(linear, in_place=True)[0]
157+
scale_space = linear
158+
159+
# Convert to the target gamut
160+
mapcolor = color.convert(scale_space).normalize(nans=False)
161+
162+
# Grab the white point and the luminance of the current gamut.
163+
white = mapcolor._space.WHITE
164+
Y = mapcolor.luminance()
165+
166+
# Scale the color into gamut
167+
rgb = cs.from_base(mapcolor[:-1]) if coerced else mapcolor[:-1]
168+
mn = min(min(rgb), 0.0) if not clip_negative else 0.0
169+
mx = max(rgb) - mn
170+
for i in range(len(rgb)):
171+
rgb[i] = alg.clamp((rgb[i] - mn) / mx if mx else (rgb[i] - mn), 0.0, 1.0) * maximum
172+
mapcolor[:-1] = cs.to_base(rgb) if coerced else rgb
173+
174+
# If the current luminance is greater than the original luminance,
175+
# set the luminance to the original. Set it xyY with the same white point.
176+
if not max_saturation:
177+
adjust_luminance(mapcolor, Y, white)
178+
179+
# Clip in the original gamut bound color space and update the original color
180+
clip_channels(mapcolor.convert(orig_space, in_place=True))
181+
color.update(mapcolor)
182+
183+
28184
def clip_channels(color: Color, nans: bool = True) -> bool:
29185
"""Clip channels."""
30186

0 commit comments

Comments
 (0)