Skip to content

Commit b7e4981

Browse files
authored
Enhancements to Cubic GMA (#522)
- Calculate Cubic coefficients such that each row contains the iteration of all coefficients. - Account for different default CAT matrices
1 parent a86bdd4 commit b7e4981

2 files changed

Lines changed: 40 additions & 39 deletions

File tree

coloraide/gamut/fit_oklch_cubic.py

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@
2727
import math
2828
from functools import lru_cache
2929
from . import Fit, clip_channels
30+
from .fit_raytrace import get_conversion_matrices
3031
from .. import util
31-
from ..cat import WHITES, calc_adaptation_matrices, Bradford
32+
from ..cat import WHITES, CAT
3233
from .. import algebra as alg
3334
from ..spaces import Space, RGBish
34-
from ..types import Vector, VectorLike, Matrix
35-
from ..spaces.oklab import LMS_TO_XYZD65, OKLAB_TO_LMS3
35+
from ..types import VectorLike, Matrix
36+
from ..spaces.oklab import OKLAB_TO_LMS3
3637
from typing import Any, TYPE_CHECKING # noqa: F401
3738

3839
if TYPE_CHECKING: #pragma: no cover
@@ -51,32 +52,42 @@ def first_root(coeff: VectorLike, mn: float, mx: float) -> float:
5152
return best
5253

5354

54-
def get_hue_data(name: str, cs: Space, h: float) -> tuple[Vector, Vector, Vector, float, list[float]]:
55+
def get_hue_data(name: str, cs: Space, cat: str, adapt: CAT, h: float) -> tuple[Matrix, float, list[float]]:
5556
"""
5657
Get hue data.
5758
5859
At fixed L and H, each linear RGB channel is exactly cubic in chroma c:
5960
6061
```
61-
channelᵢ(c) = L³ + 3L²·Aᵢ·c + 3L·Bᵢ·c² + Dᵢ·c³
62+
channelᵢ(c) = Aᵢ·c³ + 3L·Bᵢ·c² + 3L²·Dᵢ·c + L³
6263
```
6364
6465
because `l'ₖ = L + Qₖ·c` is affine in c (the a, b axes enter Oklab→LMS
6566
linearly), and `channelᵢ = Σₖ Tᵢₖ·l'ₖ³`. The constant is `L³` since `c = 0` is
6667
gray and the rows of LMS to RGB sum to 1.
6768
"""
6869

69-
m = get_lms_to_rgb(name, cs)
70+
m = get_conversion_matrices(name, cs, cat, adapt)[0]
7071
rad = math.radians(h)
71-
q = alg.matmul_x3(OKLAB_TO_LMS3, [0, math.cos(rad), math.sin(rad)], dims=alg.D2_D1)
72-
a = alg.matmul_x3(m, q, dims=alg.D2_D1)
73-
b = alg.matmul_x3(m, [x * x for x in q], dims=alg.D2_D1)
74-
d = alg.matmul_x3(m, [x ** 3 for x in q], dims=alg.D2_D1)
72+
q1, q2, q3 = alg.matmul_x3(
73+
OKLAB_TO_LMS3,
74+
[0, math.cos(rad), math.sin(rad)],
75+
dims=alg.D2_D1
76+
)
77+
m2 = alg.matmul_x3(
78+
m,
79+
[
80+
[q1 ** 3, q1 ** 2, q1],
81+
[q2 ** 3, q2 ** 2, q2],
82+
[q3 ** 3, q3 ** 2, q3]
83+
],
84+
dims=alg.D2
85+
)
7586

7687
# Substituting `c = L·t` factors L out entirely: `channelᵢ(c) = L³·Pᵢ(t)` with
7788
#
7889
# ```
79-
# Pᵢ(t) = 1 + 3Aᵢ·t + 3Bᵢ·t² + Dᵢ·t³
90+
# Pᵢ(t) = Aᵢ·t³ + 3Bᵢ·t² + 3Dᵢ·t + 1
8091
# ```
8192
#
8293
# so `Pᵢ` — hence the lower-gamut exit and the monotonicity structure — depends
@@ -89,32 +100,17 @@ def get_hue_data(name: str, cs: Space, h: float) -> tuple[Vector, Vector, Vector
89100
t_lower = math.inf
90101
# First turning point of each channel in t-space (infinity if monotonic)
91102
turn = [0.0] * 3
92-
for i in range(3):
93-
t_lower = min(t_lower, first_root([d[i], 3 * b[i], 3 * a[i], 1], 1e-9, 1))
94-
turn[i] = first_root([0, d[i], 2 * b[i], a[i]], 1e-12, math.inf)
95-
return a, b, d, t_lower, turn
103+
for i, v in enumerate(m2):
104+
t_lower = min(t_lower, first_root([v[0], 3 * v[1], 3 * v[2], 1], 1e-9, 1))
105+
turn[i] = first_root([0, v[0], 2 * v[1], v[2]], 1e-12, math.inf)
106+
return m2, t_lower, turn
96107

97108

98109
@lru_cache(maxsize=1024)
99-
def get_hue_data_cached(name: str, cs: Space, h: float) -> tuple[Vector, Vector, Vector, float, list[float]]:
110+
def get_hue_data_cached(name: str, cs: Space, cat: str, adapt: CAT, h: float) -> tuple[Matrix, float, list[float]]:
100111
"""Get hue data but use a hue_cache."""
101112

102-
return get_hue_data(name, cs, h)
103-
104-
105-
@lru_cache(maxsize=10)
106-
def get_lms_to_rgb(name: str, space: Space) -> Matrix:
107-
"""Get LMS to RGB matrix."""
108-
109-
d65 = WHITES['2deg']['D65']
110-
m = space.TO_RGB # type: ignore[attr-defined]
111-
if space.WHITE != d65:
112-
m = alg.matmul_x3(
113-
m,
114-
calc_adaptation_matrices(d65, space.WHITE, Bradford.MATRIX),
115-
dims=alg.D2
116-
)
117-
return alg.matmul_x3(m, LMS_TO_XYZD65, dims=alg.D2)
113+
return get_hue_data(name, cs, cat, adapt, h)
118114

119115

120116
class OkLChCubic(Fit):
@@ -170,19 +166,23 @@ def fit(
170166
color.update('xyz-d65', WHITE)
171167
return
172168

173-
a, b, d, t_lower, turn = get_hue_data_cached(space, cs, h) if cache else get_hue_data(space, cs, h)
169+
cat = color.CHROMATIC_ADAPTATION
170+
adapt = color.CAT_MAP[cat]
171+
m, t_lower, turn = (
172+
get_hue_data_cached(space, cs, cat, adapt, h) if cache else get_hue_data(space, cs, cat, adapt, h)
173+
)
174174
# Work in `t = c/L`. The cap starts at the input chroma and the (hue-only) lower
175175
# exit; the white bound below can only pull it lower.
176176
max_t = min(c / l, t_lower)
177177
target = 1 / (l ** 3)
178-
for i in range(3):
178+
for i, v in enumerate(m):
179179
if turn[i] > max_t:
180-
if a[i] < 0:
180+
if v[2] < 0:
181181
continue
182-
p_max_t = ((d[i] * max_t + 3 * b[i]) * max_t + 3 * a[i]) * max_t + 1
182+
p_max_t = ((v[0] * max_t + 3 * v[1]) * max_t + 3 * v[2]) * max_t + 1
183183
if p_max_t < target:
184184
continue
185-
max_t = min(max_t, first_root([d[i], 3 * b[i], 3 * a[i], 1 - target], 1e-9, max_t))
185+
max_t = min(max_t, first_root([v[0], 3 * v[1], 3 * v[2], 1 - target], 1e-9, max_t))
186186

187187
mapcolor[1] = l * max_t
188188
clip_channels(mapcolor.convert(orig_space, in_place=True))

docs/src/markdown/about/changelog.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ icon: lucide/scroll-text
55

66
## 8.10
77

8-
- **NEW** Provide an more optimized path for the default Ray Trace when using OkLCh as the perceptual space and gamut
9-
mapping linear RGB gamuts.
8+
- **NEW**: Provide a more optimized path for the default Ray Trace GMA when using OkLCh as the perceptual space and
9+
gamut mapping linear RGB gamuts.
10+
- **NEW**: Ensure new `oklch-cubic` GMA handles different default CAT matrices.
1011

1112
## 8.9
1213

0 commit comments

Comments
 (0)