Skip to content

Commit 190e545

Browse files
committed
Add sCAM
Resolves #485
1 parent 8211e60 commit 190e545

File tree

2 files changed

+329
-0
lines changed

2 files changed

+329
-0
lines changed

coloraide/everything.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from .spaces.cam16_ucs import CAM16UCS, CAM16LCD, CAM16SCD
3333
from .spaces.hellwig import HellwigJMh, HellwigHKJMh
3434
from .spaces.zcam import ZCAMJMh
35+
from .spaces.scam import sCAMJMh
3536
from .spaces.hct import HCT
3637
from .spaces.ucs import UCS
3738
from .spaces.rec709 import Rec709
@@ -101,6 +102,7 @@ class ColorAll(Base):
101102
RYBBiased(),
102103
Cubehelix(),
103104
ZCAMJMh(),
105+
sCAMJMh(),
104106

105107
# Delta E
106108
DE99o(),

coloraide/spaces/scam.py

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
"""
2+
Simple Color Appearance Model (sCAM).
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+
import bisect
9+
from .. import util
10+
from .. import algebra as alg
11+
from .lch import LCh
12+
from ..cat import WHITES
13+
from ..channels import Channel, FLG_ANGLE
14+
from ..types import Vector, VectorLike
15+
from .cam16 import hue_quadrature, inv_hue_quadrature
16+
17+
# LMS matrices
18+
TO_LMS = [
19+
[0.4002, 0.7075, -0.0807],
20+
[-0.2280, 1.1500, 0.0612],
21+
[0.0000, 0.0000, 0.9184],
22+
]
23+
FROM_LMS = alg.inv(TO_LMS)
24+
25+
# IAB transformation matrices
26+
TO_IAB = [
27+
alg.divide([200.0, 100.0, 5.0], 3.05, dims=alg.D1_SC),
28+
[430.0, -470.0, 40.0],
29+
[49.0, 49.0, -98.0]
30+
]
31+
FROM_IAB = alg.inv(TO_IAB)
32+
33+
SURROUND = {
34+
'dark': (0.39, 0.85),
35+
'dim': (0.5, 0.95),
36+
'average': (0.52, 1)
37+
}
38+
39+
HUE_QUADRATURE = {
40+
# Red, Yellow, Green, Blue, Red
41+
"h": (15.6, 80.3, 157.8, 219.7, 376.6),
42+
"e": (0.7, 0.6, 1.2, 0.9, 0.7),
43+
"H": (0.0, 100.0, 200.0, 300.0, 400.0)
44+
}
45+
46+
47+
def eccentricity(h: float) -> float:
48+
"""Calculate eccentricity."""
49+
50+
return 1 + 0.06 * math.cos(math.radians(110 + h))
51+
52+
53+
class Environment:
54+
"""
55+
Class to calculate and contain any required environmental data (viewing conditions included).
56+
57+
Usage Guidelines for CIECAM97s (Nathan Moroney)
58+
https://www.researchgate.net/publication/220865484_Usage_guidelines_for_CIECAM97s
59+
60+
`white`: This is the (x, y) chromaticity points for the white point. This should be the same
61+
value as set in the color class `WHITE` value.
62+
63+
`adapting_luminance`: This is the luminance of the adapting field. The units are in cd/m2.
64+
The equation is `L = (E * R) / π`, where `E` is the illuminance in lux, `R` is the reflectance,
65+
and `L` is the luminance. If we assume a perfectly reflecting diffuser, `R` is assumed as 1.
66+
For the "gray world" assumption, we must also divide by 5 (or multiply by 0.2 - 20%).
67+
This results in `La = E / π * 0.2`. You can also ignore this gray world assumption converting
68+
lux directly to nits (cd/m2) `lux / π`.
69+
70+
`background_luminance`: The background is the region immediately surrounding the stimulus and
71+
for images is the neighboring portion of the image. Generally, this value is set to a value of 20.
72+
This implicitly assumes a gray world assumption.
73+
74+
`surround`: The surround is categorical and is defined based on the relationship between the relative
75+
luminance of the surround and the luminance of the scene or image white. While there are 4 defined
76+
surrounds, usually just `average`, `dim`, and `dark` are used.
77+
78+
Dark | 0% | Viewing film projected in a dark room
79+
Dim | 0% to 20% | Viewing television
80+
Average | > 20% | Viewing surface colors
81+
82+
`discounting`: Whether we are discounting the illuminance. Done when eye is assumed to be fully adapted.
83+
"""
84+
85+
def __init__(
86+
self,
87+
*,
88+
white: VectorLike,
89+
adapting_luminance: float,
90+
background_luminance: float,
91+
surround: str,
92+
discounting: bool
93+
):
94+
"""
95+
Initialize environmental viewing conditions.
96+
97+
Using the specified viewing conditions, and general environmental data,
98+
initialize anything that we can ahead of time to speed up the process.
99+
"""
100+
101+
self.discounting = discounting
102+
self.ref_white = util.xy_to_xyz(white)
103+
self.output_white = util.xy_to_xyz(WHITES['2deg']['D65'])
104+
self.surround = surround
105+
106+
# The average luminance of the environment in `cd/m^2cd/m` (a.k.a. nits)
107+
self.la = adapting_luminance
108+
# The relative luminance of the nearby background
109+
self.yb = background_luminance
110+
# Absolute luminance of the reference white.
111+
xyz_w = util.scale100(self.ref_white)
112+
self.yw = xyz_w[1]
113+
114+
# Surround: dark, dim, and average
115+
self.c, self.fm = SURROUND[self.surround]
116+
117+
# Factor of luminance level adaptation
118+
self.fl = 0.1710 * (self.la ** (1 / 3)) * (1 / (1 - 0.4934 * math.exp(-0.9934 * self.la)))
119+
self.n = self.yb / self.yw
120+
self.z = 1.48 + math.sqrt(self.n)
121+
self.cz = self.c * self.z
122+
123+
124+
def sucs_to_xyz(ich: Vector) -> Vector:
125+
"""From sUCS to XYZ."""
126+
127+
i, c, h = ich
128+
c = (math.exp(0.0252 * c) - 1) / 0.0447
129+
r = math.radians(h)
130+
a, b = c * math.cos(r), c * math.sin(r)
131+
lms = [alg.nth_root(x, 0.43) for x in alg.matmul_x3(FROM_IAB, [i, a, b], dims=alg.D2_D1)]
132+
return alg.matmul_x3(FROM_LMS, lms, dims=alg.D2_D1)
133+
134+
135+
def xyz_to_sucs(xyz: Vector) -> Vector:
136+
"""From XYZ to sUCS."""
137+
138+
lms_p = [math.copysign(abs(i) ** 0.43, i) for i in alg.matmul_x3(TO_LMS, xyz, dims=alg.D2_D1)]
139+
i, a, b = alg.matmul_x3(TO_IAB, lms_p, dims=alg.D2_D1)
140+
c = (1 / 0.0252) * math.log(1 + 0.0447 * math.sqrt(a ** 2 + b ** 2))
141+
h = math.atan2(b, a) % math.tau
142+
return [i, c, math.degrees(h)]
143+
144+
145+
def scam_to_xyz(
146+
J: float | None = None,
147+
C: float | None = None,
148+
h: float | None = None,
149+
Q: float | None = None,
150+
M: float | None = None,
151+
D: float | None = None,
152+
V: float | None = None,
153+
W: float | None = None,
154+
K: float | None = None,
155+
H: float | None = None,
156+
env: Environment | None = None
157+
) -> Vector:
158+
"""
159+
From sCAM to XYZ.
160+
161+
Reverse calculation can actually be obtained from a small subset of the sCAM components
162+
Really, only one suitable value is needed for each type of attribute: (lightness/brightness),
163+
(chroma/colorfulness/depth/vividness/whiteness/blackness), (hue/hue quadrature). If more than one for a given
164+
category is given, we will fail as we have no idea which is the right one to use. Also,
165+
if none are given, we must fail as well as there is nothing to calculate with.
166+
"""
167+
168+
# These check ensure one, and only one attribute for a given category is provided.
169+
if not ((J is not None) ^ (Q is not None)):
170+
raise ValueError("Conversion requires one and only one: 'J' or 'Q'")
171+
172+
if not ((C is not None) ^ (M is not None) ^ (D is not None) ^ (V is not None) ^ (W is not None) ^ (K is not None)):
173+
raise ValueError("Conversion requires one and only one: 'C', 'M', 'D', 'V', 'W', 'K'")
174+
175+
# Hue is absolutely required
176+
if not ((h is not None) ^ (H is not None)):
177+
raise ValueError("Conversion requires one and only one: 'h' or 'H'")
178+
179+
# We need viewing conditions
180+
if env is None:
181+
raise ValueError("No viewing conditions/environment provided")
182+
183+
# Calculate hue
184+
if h is not None:
185+
h %= 360
186+
if h is None and H is not None:
187+
h = inv_hue_quadrature(H)
188+
189+
# Calculate `I` from one of the lightness derived coordinates.
190+
Ia = 0.0
191+
if J is not None:
192+
Ia = J
193+
elif Q is not None:
194+
Ia = Q / ((2 * (env.fl ** 0.46)) / env.c)
195+
I = alg.nth_root(Ia * 0.01, env.cz) * 100
196+
197+
# Calculate the chroma component
198+
if W is not None:
199+
D = 100 - W
200+
elif K is not None:
201+
V = 100 - K
202+
if D is not None:
203+
C = alg.nth_root(((D / 1.3) ** 2 - (100 - Ia) ** 2) / 1.6, 2)
204+
elif V is not None:
205+
C = alg.nth_root((V ** 2 - Ia ** 2) / 3, 2)
206+
elif M is not None:
207+
et = eccentricity(h)
208+
C = M * alg.spow(Ia, 0.27) / ((env.fl ** 0.1) * et * env.fm)
209+
210+
# Convert to XYZ from sUCS
211+
return sucs_to_xyz([I, C, h])
212+
213+
214+
def xyz_to_scam(xyz: Vector, env: Environment, calc_hue_quadrature: bool = True) -> Vector:
215+
"""From XYZ to sCAM."""
216+
217+
# Convert from XYZ to sUCS
218+
I, C, h = xyz_to_sucs(xyz)
219+
220+
# Eccentricity
221+
et = eccentricity(h)
222+
223+
# Lightness
224+
Ia = 100 * alg.spow(I * 0.01, env.cz)
225+
226+
# Brightness
227+
Q = Ia * ((2 * (env.fl ** 0.46)) / env.c)
228+
229+
# Colorfulness
230+
M = (C * (env.fl ** 0.1) * et) * alg.zdiv(1, alg.spow(Ia, 0.27), 0.0) * env.fm
231+
232+
# Depth
233+
D = 1.3 * math.sqrt((100 - Ia) ** 2 + 1.6 * C ** 2)
234+
235+
# Vividness
236+
V = math.sqrt(Ia ** 2 + 3 * C ** 2)
237+
238+
# Whiteness
239+
W = 100 - D
240+
241+
# Blackness
242+
K = 100 - V
243+
244+
# Hue quadrature if required
245+
H = hue_quadrature(h) if calc_hue_quadrature else alg.NaN
246+
247+
return [Ia, C, h, Q, M, D, V, W, K, H]
248+
249+
250+
def xyz_to_scam_jmh(xyz: Vector, env: Environment) -> Vector:
251+
"""XYZ to sCAM JMh."""
252+
253+
scam = xyz_to_scam(xyz, env)
254+
return [scam[0], scam[4], scam[2]]
255+
256+
257+
def scam_jmh_to_xyz(jmh: Vector, env: Environment) -> Vector:
258+
"""sCAM JMh to XYZ."""
259+
260+
J, M, h = jmh
261+
return scam_to_xyz(J=J, M=M, h=h, env=env)
262+
263+
264+
class sCAMJMh(LCh):
265+
"""sCAM class (JMh)."""
266+
267+
BASE = "xyz-d65"
268+
NAME = "scam-jmh"
269+
SERIALIZE = ("--scam-jmh",)
270+
CHANNEL_ALIASES = {
271+
"lightness": "j",
272+
"colorfulness": 'm',
273+
"hue": 'h'
274+
}
275+
WHITE = WHITES['2deg']['D65']
276+
# Assuming sRGB which has a lux of 64: `((E * R) / PI) / 5` where `R = 1`.
277+
ENV = Environment(
278+
# Our white point.
279+
white=WHITE,
280+
# Assuming sRGB which has a lux of 64: `((E * R) / PI)` where `R = 1`.
281+
# Divided by 5 (or multiplied by 20%) assuming gray world.
282+
adapting_luminance=64 / math.pi * 0.2,
283+
# Gray world assumption, 20% of reference white's `Yw = 100`.
284+
background_luminance=20,
285+
# Average surround
286+
surround='average',
287+
# Do not discount illuminant
288+
discounting=False
289+
)
290+
CHANNELS = (
291+
Channel("j", 0.0, 100.0),
292+
Channel("m", 0, 105.0),
293+
Channel("h", 0.0, 360.0, flags=FLG_ANGLE)
294+
)
295+
296+
def lightness_name(self) -> str:
297+
"""Get lightness name."""
298+
299+
return "j"
300+
301+
def radial_name(self) -> str:
302+
"""Get radial name."""
303+
304+
return "m"
305+
306+
def is_achromatic(self, coords: Vector) -> bool:
307+
"""Check if color is achromatic."""
308+
309+
return coords[0] == 0.0 or abs(coords[1]) < self.achromatic_threshold
310+
311+
def normalize(self, coords: Vector) -> Vector:
312+
"""Normalize."""
313+
314+
if coords[1] < 0.0:
315+
return self.from_base(self.to_base(coords))
316+
coords[2] %= 360.0
317+
return coords
318+
319+
def to_base(self, coords: Vector) -> Vector:
320+
"""From sCAM JMh to XYZ."""
321+
322+
return scam_jmh_to_xyz(coords, self.ENV)
323+
324+
def from_base(self, coords: Vector) -> Vector:
325+
"""From XYZ to sCAM JMh."""
326+
327+
return xyz_to_scam_jmh(coords, self.ENV)

0 commit comments

Comments
 (0)