Skip to content

Commit ae2320e

Browse files
committed
Add sUCS and use the same D65 white point as ITP
1 parent 01f0bf9 commit ae2320e

File tree

4 files changed

+100
-43
lines changed

4 files changed

+100
-43
lines changed

coloraide/everything.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from .spaces.hellwig import HellwigJMh, HellwigHKJMh
3434
from .spaces.zcam import ZCAMJMh
3535
from .spaces.scam import sCAMJMh
36+
from .spaces.sucs import sUCS
3637
from .spaces.hct import HCT
3738
from .spaces.ucs import UCS
3839
from .spaces.rec709 import Rec709
@@ -103,6 +104,7 @@ class ColorAll(Base):
103104
Cubehelix(),
104105
ZCAMJMh(),
105106
sCAMJMh(),
107+
sUCS(),
106108

107109
# Delta E
108110
DE99o(),

coloraide/spaces/ipt.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from .. import util
1212
from ..types import Vector
1313

14+
D65 = tuple(util.xyz_to_xyY([0.9504, 1.0, 1.0889])[:-1]) # type: tuple[float, float] # type: ignore[assignment]
15+
1416
# IPT matrices for LMS conversion with better accuracy for 64 bit doubles
1517
XYZ_TO_LMS = [
1618
[0.40021823485770675, 0.7075142362766385, -0.08070681117219487],
@@ -71,7 +73,7 @@ class IPT(Lab):
7173
# The D65 white point used in the paper was different than what we use.
7274
# We use chromaticity points (0.31270, 0.3290) which gives us an XYZ of ~[0.9505, 1.0000, 1.0890]
7375
# IPT uses XYZ of [0.9504, 1.0, 1.0889] which yields chromaticity points ~(0.3127035830618893, 0.32902313032606195)
74-
WHITE = tuple(util.xyz_to_xyY([0.9504, 1.0, 1.0889])[:-1]) # type: ignore[assignment]
76+
WHITE = D65
7577

7678
def lightness_name(self) -> str:
7779
"""Get lightness name."""

coloraide/spaces/scam.py

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,11 @@
88
from .. import util
99
from .. import algebra as alg
1010
from .lch import LCh
11-
from ..cat import WHITES
1211
from ..channels import Channel, FLG_ANGLE
13-
from ..types import Vector, VectorLike
1412
from .cam16 import hue_quadrature, inv_hue_quadrature, M16, M16_INV
15-
16-
# LMS matrices
17-
TO_LMS = [
18-
[0.4002, 0.7075, -0.0807],
19-
[-0.2280, 1.1500, 0.0612],
20-
[0.0000, 0.0000, 0.9184],
21-
]
22-
FROM_LMS = alg.inv(TO_LMS)
23-
24-
# Iab transformation matrices
25-
TO_IAB = [
26-
alg.divide([200.0, 100.0, 5.0], 3.05, dims=alg.D1_SC),
27-
[430.0, -470.0, 40.0],
28-
[49.0, 49.0, -98.0]
29-
]
30-
FROM_IAB = alg.inv(TO_IAB)
13+
from .sucs import xyz_to_sucs, sucs_to_xyz
14+
from .ipt import D65
15+
from ..types import Vector, VectorLike
3116

3217
SURROUND = {
3318
'dark': (0.39, 0.85),
@@ -128,7 +113,7 @@ def __init__(
128113
self.yw = self.input_white[1]
129114
# Destination luminance
130115
self.output_white = alg.multiply_x3(
131-
util.xy_to_xyz(WHITES['2deg']['D65']),
116+
util.xy_to_xyz(D65),
132117
(self.la * 100) / self.yb,
133118
dims=alg.D1_SC
134119
)
@@ -145,27 +130,6 @@ def __init__(
145130
self.d = alg.clamp(self.fm * (1 - 1 / 3.6 * math.exp((-self.la - 42) / 92)), 0, 1) if not discounting else 1
146131

147132

148-
def sucs_to_xyz(ich: Vector) -> Vector:
149-
"""From sUCS to XYZ."""
150-
151-
i, c, h = ich
152-
c = (math.exp(0.0252 * c) - 1) / 0.0447
153-
r = math.radians(h)
154-
a, b = c * math.cos(r), c * math.sin(r)
155-
lms = [alg.nth_root(x, 0.43) for x in alg.matmul_x3(FROM_IAB, [i, a, b], dims=alg.D2_D1)]
156-
return alg.matmul_x3(FROM_LMS, lms, dims=alg.D2_D1)
157-
158-
159-
def xyz_to_sucs(xyz: Vector) -> Vector:
160-
"""From XYZ to sUCS."""
161-
162-
lms_p = [math.copysign(abs(i) ** 0.43, i) for i in alg.matmul_x3(TO_LMS, xyz, dims=alg.D2_D1)]
163-
i, a, b = alg.matmul_x3(TO_IAB, lms_p, dims=alg.D2_D1)
164-
c = (1 / 0.0252) * math.log(1 + 0.0447 * math.sqrt(a ** 2 + b ** 2))
165-
h = math.atan2(b, a) % math.tau
166-
return [i, c, math.degrees(h)]
167-
168-
169133
def scam_to_xyz(
170134
J: float | None = None,
171135
C: float | None = None,
@@ -233,12 +197,15 @@ def scam_to_xyz(
233197

234198
# Convert to XYZ from sUCS
235199
xyz = sucs_to_xyz([I, C, h]) # type: ignore[list-item]
200+
201+
# Apply chromatic adaptation
236202
return adapt(xyz, env.output_white, env.input_white, env.d)
237203

238204

239205
def xyz_to_scam(xyz: Vector, env: Environment, calc_hue_quadrature: bool = True) -> Vector:
240206
"""From XYZ to sCAM."""
241207

208+
# Apply chromatic adaptation
242209
xyz = adapt(xyz, env.input_white, env.output_white, env.d)
243210

244211
# Convert from XYZ to sUCS
@@ -299,7 +266,8 @@ class sCAMJMh(LCh):
299266
"colorfulness": 'm',
300267
"hue": 'h'
301268
}
302-
WHITE = WHITES['2deg']['D65']
269+
# WHITE = tuple(util.xyz_to_xyY([0.9504, 1.0, 1.0889])[:-1])
270+
WHITE = D65
303271
# Assuming sRGB which has a lux of 64: `((E * R) / PI) / 5` where `R = 1`.
304272
ENV = Environment(
305273
# Our white point.
@@ -315,7 +283,7 @@ class sCAMJMh(LCh):
315283
discounting=False
316284
)
317285
CHANNELS = (
318-
Channel("j", 0.0, 99.999),
286+
Channel("j", 0.0, 100.0),
319287
Channel("m", 0, 25.0),
320288
Channel("h", 0.0, 360.0, flags=FLG_ANGLE)
321289
)

coloraide/spaces/sucs.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""
2+
Simple Color Appearance Model (sUCS).
3+
4+
https://opg.optica.org/oe/fulltext.cfm?uri=oe-32-3-3100&id=545619
5+
"""
6+
from __future__ import annotations
7+
import math
8+
from .. import util
9+
from .. import algebra as alg
10+
from .lch import LCh
11+
from ..cat import WHITES
12+
from ..channels import Channel, FLG_ANGLE
13+
from ..types import Vector
14+
from .ipt import XYZ_TO_LMS, LMS_TO_XYZ, D65
15+
16+
# Iab transformation matrices
17+
TO_IAB = [
18+
alg.divide([200.0, 100.0, 5.0], 3.05, dims=alg.D1_SC),
19+
[430.0, -470.0, 40.0],
20+
[49.0, 49.0, -98.0]
21+
]
22+
FROM_IAB = alg.inv(TO_IAB)
23+
24+
25+
def sucs_to_xyz(ich: Vector) -> Vector:
26+
"""From sUCS to XYZ."""
27+
28+
i, c, h = ich
29+
c = (math.exp(0.0252 * c) - 1) / 0.0447
30+
r = math.radians(h)
31+
a, b = c * math.cos(r), c * math.sin(r)
32+
lms = [alg.nth_root(x, 0.43) for x in alg.matmul_x3(FROM_IAB, [i, a, b], dims=alg.D2_D1)]
33+
return alg.matmul_x3(LMS_TO_XYZ, lms, dims=alg.D2_D1)
34+
35+
36+
def xyz_to_sucs(xyz: Vector) -> Vector:
37+
"""From XYZ to sUCS."""
38+
39+
lms_p = [math.copysign(abs(i) ** 0.43, i) for i in alg.matmul_x3(XYZ_TO_LMS, xyz, dims=alg.D2_D1)]
40+
i, a, b = alg.matmul_x3(TO_IAB, lms_p, dims=alg.D2_D1)
41+
c = (1 / 0.0252) * math.log(1 + 0.0447 * math.sqrt(a ** 2 + b ** 2))
42+
h = math.atan2(b, a) % math.tau
43+
return [i, c, math.degrees(h)]
44+
45+
46+
class sUCS(LCh):
47+
"""sUCS class (JMh)."""
48+
49+
BASE = "xyz-d65"
50+
NAME = "sucs"
51+
SERIALIZE = ("--sucs",)
52+
CHANNEL_ALIASES = {
53+
"lightness": "i",
54+
"chroma": 'c',
55+
"hue": 'h'
56+
}
57+
WHITE = D65
58+
CHANNELS = (
59+
Channel("i", 0.0, 100),
60+
Channel("c", 0, 65.0),
61+
Channel("h", 0.0, 360.0, flags=FLG_ANGLE)
62+
)
63+
64+
def lightness_name(self) -> str:
65+
"""Get lightness name."""
66+
67+
return "i"
68+
69+
def normalize(self, coords: Vector) -> Vector:
70+
"""Normalize."""
71+
72+
if coords[1] < 0.0:
73+
return self.from_base(self.to_base(coords))
74+
coords[2] %= 360.0
75+
return coords
76+
77+
def to_base(self, coords: Vector) -> Vector:
78+
"""From sCAM JMh to XYZ."""
79+
80+
return sucs_to_xyz(coords)
81+
82+
def from_base(self, coords: Vector) -> Vector:
83+
"""From XYZ to sCAM JMh."""
84+
85+
return xyz_to_sucs(coords)

0 commit comments

Comments
 (0)