Skip to content

Commit 397d971

Browse files
authored
Add a luminance preserving version of scale GMA (#506)
1 parent 0d0729e commit 397d971

File tree

18 files changed

+359
-105
lines changed

18 files changed

+359
-105
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, 4, 0, "final")
207+
__version_info__ = Version(8, 5, 0, "final")
208208
__version__ = __version_info__._get_canonical()

coloraide/algebra.py

Lines changed: 80 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,58 +1182,90 @@ 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,
1185+
def point_on_segment(a: VectorLike, b: VectorLike, p: VectorLike, abs_tol: float = ATOL) -> bool:
1186+
"""Point on line segment."""
1187+
1188+
l = len(p)
1189+
1190+
ab = [b[i] - a[i] for i in range(l)]
1191+
ap = [p[i] - a[i] for i in range(l)]
1192+
1193+
# Check if `ap` is parallel to `ab` (cross product is zero)
1194+
cp = cross(ab, ap)
1195+
if _any(abs(c) > abs_tol for c in cp):
1196+
return False
1197+
1198+
# Check if the point is between a and b
1199+
for i in range(l):
1200+
if abs(ab[i]) < abs_tol:
1201+
continue
1202+
t = ap[i] / ab[i]
1203+
break
1204+
1205+
# See if `ab` is a point and `p` is that point
1206+
else:
1207+
return _all(abs(i - j) < abs_tol for i, j in zip(a, p))
1208+
1209+
return 0 <= t <= 1
1210+
1211+
1212+
def line_interesect(
1213+
s1: VectorLike,
1214+
e1: VectorLike,
1215+
s2: VectorLike,
1216+
e2: VectorLike,
1217+
rel_tol: float = RTOL,
11901218
abs_tol: float = ATOL
11911219
) -> 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
1220+
"""
1221+
Find intersection of two lines.
1222+
1223+
This was designed particularly for 3D intersection, but can be used for either 2D or 3D,
1224+
but 2D line intersection could be calculated with less work using other methods if performance
1225+
was of importance.
1226+
1227+
3D lines rarely intersect, but often the shortest line between can be found.
1228+
If the shortest line is has no length (a point) then it is an actual intersection.
1229+
Our cases are constructed such that an intersection is expected, and a line is not sufficient.
1230+
We can verify closeness of the points (to account for floating point errors) to verify that within
1231+
some expected threshold, the two line points are essentially a point and an intersection is found.
1232+
"""
1233+
1234+
# Line segment difference
1235+
l1 = [a - b for a, b in zip(e1, s1)]
1236+
l2 = [a - b for a, b in zip(e2, s2)]
1237+
1238+
# Magnitude
1239+
m1 = math.sqrt(sum(a ** 2 for a in l1))
1240+
m2 = math.sqrt(sum(a ** 2 for a in l2))
1241+
1242+
# One of the lines is a point.
1243+
if m1 < abs_tol:
1244+
return list(s1) if point_on_segment(e2, s2, s1, abs_tol=abs_tol) else None
1245+
elif m2 < abs_tol:
1246+
return list(s2) if point_on_segment(e1, s1, s2, abs_tol=abs_tol) else None
1247+
1248+
# Unit vector
1249+
u1 = [a / m1 for a in l1]
1250+
u2 = [a / m2 for a in l2]
1251+
# Direction projection
1252+
u = vdot(u1, u2)
1253+
if u == 1: # pragma: no cover
12011254
return None
1202-
t = dot(dap, dp, dims=D1) / denom
1203-
# Check if intersection is within bounds
1204-
if 0 <= t <= 1:
1205-
# Intersect
1206-
return add(b1, multiply(t, db, dims=SC_D1), dims=D1)
1207-
return None # pragma: no cover
1208-
1209-
1210-
def line_intersect(
1211-
a1: VectorLike,
1212-
a2: VectorLike,
1213-
b1: VectorLike,
1214-
b2: VectorLike,
1215-
abs_tol: float = ATOL
1216-
) -> Vector | None: # pragma: no cover
1217-
"""Find the intersection of of lines."""
1218-
1219-
da = [a - b for a, b in zip(a2, a1)]
1220-
db = [a - b for a, b in zip(b2, b1)]
1221-
dp = [a - b for a, b in zip(a1, b1)]
1222-
dap = [-da[1], da[0]]
1223-
denom = dot(dap, db, dims=D1)
1224-
# Perpendicular cases
1225-
if abs(denom) < abs_tol: # pragma: no cover
1255+
# Separation projection
1256+
sp = [a - b for a, b in zip(s2, s1)]
1257+
sp1 = vdot(sp, u1)
1258+
sp2 = vdot(sp, u2)
1259+
# Distance along lines
1260+
d1 = (sp1 - u * sp2) / (1 - u * u)
1261+
d2 = (sp2 - u * sp1) / (u * u - 1)
1262+
# Calculate points of closest line
1263+
p1 = [a + b for a, b in zip(s1, [x * d1 for x in u1])]
1264+
p2 = [a + b for a, b in zip(s2, [x * d2 for x in u2])]
1265+
# If points are close enough, assume intersect, otherwise raise error
1266+
if not _all(math.isclose(i, j, rel_tol=rel_tol, abs_tol=abs_tol) for i, j in zip(p1, p2)): # pragma: no cover
12261267
return None
1227-
t = dot(dap, dp, dims=D1) / denom
1228-
# Intersect
1229-
i = add(b1, multiply(t, db, dims=SC_D1), dims=D1)
1230-
# Check if intersection is within bounds
1231-
if (
1232-
0 <= t <= 1 and
1233-
0 <= divide(dot(subtract(i, a1, dims=D1), da, dims=D1), dot(da, da, dims=D1), dims=SC) <= 1
1234-
):
1235-
return i
1236-
return None
1268+
return p1
12371269

12381270

12391271
def all(a: float | ArrayLike) -> bool: # noqa: A001

coloraide/color.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
from .gamut.fit_oklch_chroma import OkLChChroma
7171
from .gamut.fit_raytrace import RayTrace
7272
from .gamut.fit_scale import Scale
73+
from .gamut.fit_scale_luminance import ScaleLuminance
7374
from .cat import CAT, Bradford
7475
from .filters import Filter
7576
from .filters.w3c_filter_effects import Sepia, Brightness, Contrast, Saturate, Opacity, HueRotate, Grayscale, Invert
@@ -529,6 +530,7 @@ def blackbody(
529530
scale_space: str | None = None,
530531
max_saturation: bool = True,
531532
clip_negative: bool = False,
533+
preserve_luminance: bool = False,
532534
**kwargs: Any
533535
) -> Self:
534536
"""
@@ -553,7 +555,8 @@ def blackbody(
553555
scale=scale,
554556
scale_space=scale_space,
555557
max_saturation=max_saturation,
556-
clip_negative=clip_negative
558+
clip_negative=clip_negative,
559+
preserve_luminance=preserve_luminance
557560
)
558561

559562
def cct(self, *, method: str | None = None, **kwargs: Any) -> Vector:
@@ -844,7 +847,8 @@ def chromaticity(
844847
scale: bool = False,
845848
scale_space: str | None = None,
846849
max_saturation: bool = False,
847-
clip_negative: bool = False
850+
clip_negative: bool = False,
851+
preserve_luminance: bool = False
848852
) -> Self:
849853
"""
850854
Create a color from chromaticity coordinates.
@@ -882,7 +886,8 @@ def chromaticity(
882886
color,
883887
scale_space=scale_space if scale_space is not None else 'srgb-linear',
884888
max_saturation=max_saturation,
885-
clip_negative=clip_negative
889+
clip_negative=clip_negative,
890+
preserve_luminance=preserve_luminance
886891
)
887892

888893
# Convert to targeted color space
@@ -1382,7 +1387,8 @@ def from_wavelength(
13821387
scale: bool = True,
13831388
scale_space: str | None = None,
13841389
max_saturation: bool = True,
1385-
clip_negative: bool = False
1390+
clip_negative: bool = False,
1391+
preserve_luminance: bool = False
13861392
) -> Self:
13871393
"""Create a color from a wavelength."""
13881394

@@ -1394,7 +1400,8 @@ def from_wavelength(
13941400
scale=scale,
13951401
scale_space=scale_space,
13961402
max_saturation=max_saturation,
1397-
clip_negative=clip_negative
1403+
clip_negative=clip_negative,
1404+
preserve_luminance=preserve_luminance
13981405
)
13991406

14001407
@overload
@@ -1645,6 +1652,7 @@ def alpha(
16451652
OkLChChroma(),
16461653
RayTrace(),
16471654
Scale(),
1655+
ScaleLuminance(),
16481656

16491657
# Filters
16501658
Sepia(),

coloraide/gamut/__init__.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,25 +112,46 @@ def from_base(self, coords: Vector) -> Vector:
112112
return RGB()
113113

114114

115-
def adjust_luminance(color: Color, Y: float, white: VectorLike) -> None:
115+
def adjust_luminance(
116+
color: Color,
117+
Y: float,
118+
white: VectorLike,
119+
max_luminance: float = 1.0,
120+
preserve_luminance: bool = True
121+
) -> None:
116122
"""Adjust luminance of a color."""
117123

118124
with color.within('xyz-d65') as c:
119-
c.convert('xyz-d65', in_place=True)
120125
d65 = c._space.WHITE
121126
adapt = d65 != white
122127
xyz = c.chromatic_adaptation(d65, white, c[:-1]) if adapt else c[:-1]
128+
Y = alg.clamp(Y, 0.0, max_luminance)
129+
# Luminance below the cusp can just be restored
123130
if xyz[1] > Y:
124131
xyz = util.xy_to_xyz(util.xyz_to_xyY(xyz, white)[:-1], Y)
125132
c[:-1] = c.chromatic_adaptation(white, d65, xyz) if adapt else xyz
133+
# Luminance above the cusp requires us to find the intersection of the vectors of the
134+
# path between the color and white and those same colors with the adjusted luminance.
135+
elif preserve_luminance and xyz[1] < Y:
136+
xyy = util.xyz_to_xyY(xyz, white)
137+
intersect = alg.line_interesect(
138+
xyz,
139+
util.xy_to_xyz(white, max_luminance),
140+
util.xy_to_xyz(xyy[:2], Y),
141+
util.xy_to_xyz(white, Y)
142+
)
143+
# Update color if we found an intersection
144+
if intersect is not None:
145+
c[:-1] = c.chromatic_adaptation(white, d65, intersect) if adapt else intersect
126146

127147

128148
def scale_rgb(
129149
color: Color,
130150
*,
131151
scale_space: str,
132152
clip_negative: bool = False,
133-
max_saturation: bool = False
153+
max_saturation: bool = False,
154+
preserve_luminance: bool = False
134155
) -> None:
135156
"""Apply color scaling."""
136157

@@ -171,10 +192,9 @@ def scale_rgb(
171192
rgb[i] = alg.clamp((rgb[i] - mn) / mx if mx else (rgb[i] - mn), 0.0, 1.0) * maximum
172193
mapcolor[:-1] = cs.to_base(rgb) if coerced else rgb
173194

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.
195+
# Check if luminance doesn't match and update accordingly.
176196
if not max_saturation:
177-
adjust_luminance(mapcolor, Y, white)
197+
adjust_luminance(mapcolor, Y, white, maximum, preserve_luminance)
178198

179199
# Clip in the original gamut bound color space and update the original color
180200
clip_channels(mapcolor.convert(orig_space, in_place=True))
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Luminance preserving scale."""
2+
from __future__ import annotations
3+
from .fit_scale import Scale
4+
from . import scale_rgb
5+
from typing import TYPE_CHECKING, Any
6+
7+
if TYPE_CHECKING: # pragma: no cover
8+
from .. import Color
9+
10+
11+
class ScaleLuminance(Scale):
12+
"""Luminance preserving scale."""
13+
14+
NAME = 'scale-luminance'
15+
16+
def fit(
17+
self,
18+
color: Color,
19+
space: str,
20+
preserve_luminance: bool = True,
21+
**kwargs: Any
22+
) -> None:
23+
"""Scale the color within its gamut but preserve L and h as much as possible."""
24+
25+
scale_rgb(color, scale_space=space, preserve_luminance=preserve_luminance, **kwargs)

coloraide/spectrum.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,31 @@ def xy_to_angle(xy: VectorLike, white: VectorLike, offset: float = 0.0, invert:
3838
return angle
3939

4040

41+
def ray_line_intersect(
42+
a1: VectorLike,
43+
a2: VectorLike,
44+
b1: VectorLike,
45+
b2: VectorLike,
46+
abs_tol: float = alg.ATOL
47+
) -> Vector | None:
48+
"""Find the intersection of a 2D ray and line."""
49+
50+
da = [a - b for a, b in zip(a2, a1)]
51+
db = [a - b for a, b in zip(b2, b1)]
52+
dp = [a - b for a, b in zip(a1, b1)]
53+
dap = [-da[1], da[0]]
54+
denom = alg.dot(dap, db, dims=alg.D1)
55+
# Parallel cases
56+
if abs(denom) < abs_tol: # pragma: no cover
57+
return None
58+
t = alg.dot(dap, dp, dims=alg.D1) / denom
59+
# Check if intersection is within bounds
60+
if 0 <= t <= 1:
61+
# Intersect
62+
return alg.add(b1, alg.multiply(t, db, dims=alg.SC_D1), dims=alg.D1)
63+
return None # pragma: no cover
64+
65+
4166
def closest_wavelength(
4267
xy: VectorLike,
4368
white: VectorLike = WHITE,
@@ -103,7 +128,7 @@ def closest_wavelength(
103128
elif target == xy_to_angle(locus[i0], white, start):
104129
intersect = locus[i0]
105130
else:
106-
intersect = alg.ray_line_intersect(white, xy, locus[i0], locus[i])
131+
intersect = ray_line_intersect(white, xy, locus[i0], locus[i])
107132
if intersect is None: # pragma: no cover
108133
continue
109134

@@ -141,7 +166,7 @@ def closest_wavelength(
141166

142167
# If dominant isn't found, it is on the line of purples; use complementary instead
143168
if not found[reverse]:
144-
pt = alg.ray_line_intersect(white, xy, locus[0], locus[-1])
169+
pt = ray_line_intersect(white, xy, locus[0], locus[-1])
145170
# Shouldn't happen, but just in case
146171
if pt is not None: # pragma: no cover
147172
dominant = pt

docs/src/markdown/about/changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ icon: lucide/scroll-text
33
---
44
# Changelog
55

6+
## 8.5
7+
8+
- **NEW**: Add `perserve_luminance` option to `scale` to allow for a luminance preserving version.
9+
- **NEW**: Add `scale-luminance` gamut mapping method as a pre-configured luminance preserving scale method.
10+
611
## 8.4
712

813
- **NEW**: Add a new gamut mapping approach called `scale` that scales colors within a linear RGB space and can

0 commit comments

Comments
 (0)