Coverage for appearance/scam.py: 73%
123 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
1"""
2sCAM Colour Appearance Model
3============================
5Define the *sCAM* colour appearance model for predicting perceptual colour
6attributes under varying viewing conditions.
8- :class:`colour.appearance.InductionFactors_sCAM`
9- :attr:`colour.VIEWING_CONDITIONS_sCAM`
10- :class:`colour.CAM_Specification_sCAM`
11- :func:`colour.XYZ_to_sCAM`
12- :func:`colour.sCAM_to_XYZ`
14The *sCAM* (Simple Colour Appearance Model) is based on the *sUCS* (Simple
15Uniform Colour Space).
17References
18----------
19- :cite:`Li2024` : Li, M., & Luo, M. R. (2024). Simple color appearance model
20 (sCAM) based on simple uniform color space (sUCS). Optics Express, 32(3),
21 3100. doi:10.1364/OE.510196
22"""
24from __future__ import annotations
26from dataclasses import astuple, dataclass, field
28import numpy as np
30from colour.adaptation import chromatic_adaptation_Li2025
31from colour.algebra import sdiv, sdiv_mode, spow
32from colour.hints import ( # noqa: TC001
33 Annotated,
34 ArrayLike,
35 Domain100,
36 NDArrayFloat,
37 Range100,
38)
39from colour.models.sucs import (
40 XYZ_to_sUCS,
41 sUCS_Iab_to_sUCS_ICh,
42 sUCS_ICh_to_sUCS_Iab,
43 sUCS_to_XYZ,
44)
45from colour.utilities import (
46 CanonicalMapping,
47 MixinDataclassArithmetic,
48 MixinDataclassIterable,
49 as_float,
50 as_float_array,
51 domain_range_scale,
52 from_range_100,
53 from_range_degrees,
54 has_only_nan,
55 to_domain_100,
56 to_domain_degrees,
57 tsplit,
58 tstack,
59)
61__author__ = "Colour Developers"
62__copyright__ = "Copyright 2024 Colour Developers"
63__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
64__maintainer__ = "Colour Developers"
65__email__ = "colour-developers@colour-science.org"
66__status__ = "Production"
68__all__ = [
69 "TVS_D65_sCAM",
70 "HUE_DATA_FOR_HUE_QUADRATURE_sCAM",
71 "InductionFactors_sCAM",
72 "VIEWING_CONDITIONS_sCAM",
73 "CAM_Specification_sCAM",
74 "XYZ_to_sCAM",
75 "sCAM_to_XYZ",
76 "hue_quadrature",
77]
79TVS_D65_sCAM = np.array([0.95047, 1.00000, 1.08883])
80"""*CIE XYZ* tristimulus values of *CIE Standard Illuminant D65* for *sCAM*."""
82HUE_DATA_FOR_HUE_QUADRATURE_sCAM: dict = {
83 "h_i": np.array([15.6, 80.3, 157.8, 219.7, 376.6]),
84 "e_i": np.array([0.7, 0.6, 1.2, 0.9, 0.7]),
85 "H_i": np.array([0.0, 100.0, 200.0, 300.0, 400.0]),
86}
87"""Hue quadrature data for *sCAM* colour appearance model."""
90@dataclass(frozen=True)
91class InductionFactors_sCAM(MixinDataclassIterable):
92 """
93 Define the *sCAM* colour appearance model induction factors.
95 Parameters
96 ----------
97 F
98 Maximum degree of adaptation :math:`F`.
99 c
100 Exponential non-linearity :math:`c`.
101 Fm
102 Factor for colourfulness :math:`F_m`.
104 References
105 ----------
106 :cite:`Li2024`
107 """
109 F: float
110 c: float
111 Fm: float
114VIEWING_CONDITIONS_sCAM: CanonicalMapping = CanonicalMapping(
115 {
116 "Average": InductionFactors_sCAM(F=1.0, c=0.52, Fm=1.0),
117 "Dim": InductionFactors_sCAM(F=0.9, c=0.50, Fm=0.95),
118 "Dark": InductionFactors_sCAM(F=0.8, c=0.39, Fm=0.85),
119 }
120)
121VIEWING_CONDITIONS_sCAM.__doc__ = """
122Define the reference *sCAM* colour appearance model
123viewing conditions.
125Provide standardized surround conditions (*Average*, *Dim*, *Dark*) with
126their corresponding induction factors that characterize chromatic
127adaptation and perceptual non-linearities under different viewing
128environments.
129"""
132@dataclass
133class CAM_Specification_sCAM(MixinDataclassArithmetic):
134 """
135 Define the specification for the *sCAM* colour appearance model.
137 Parameters
138 ----------
139 J
140 Correlate of *lightness* :math:`J`.
141 C
142 Correlate of *chroma* :math:`C`.
143 h
144 *Hue* angle :math:`h` in degrees.
145 Q
146 Correlate of *brightness* :math:`Q`.
147 M
148 Correlate of *colourfulness* :math:`M`.
149 H
150 *Hue* :math:`h` composition :math:`H`.
151 HC
152 *Hue* :math:`h` composition :math:`H^C` (currently not
153 implemented).
154 V
155 Correlate of *vividness* :math:`V`.
156 K
157 Correlate of *blackness* :math:`K`.
158 W
159 Correlate of *whiteness* :math:`W`.
160 D
161 Correlate of *depth* :math:`D`.
163 References
164 ----------
165 :cite:`Li2024`
166 """
168 J: float | NDArrayFloat | None = field(default_factory=lambda: None)
169 C: float | NDArrayFloat | None = field(default_factory=lambda: None)
170 h: float | NDArrayFloat | None = field(default_factory=lambda: None)
171 Q: float | NDArrayFloat | None = field(default_factory=lambda: None)
172 M: float | NDArrayFloat | None = field(default_factory=lambda: None)
173 H: float | NDArrayFloat | None = field(default_factory=lambda: None)
174 HC: float | NDArrayFloat | None = field(default_factory=lambda: None)
175 V: float | NDArrayFloat | None = field(default_factory=lambda: None)
176 K: float | NDArrayFloat | None = field(default_factory=lambda: None)
177 W: float | NDArrayFloat | None = field(default_factory=lambda: None)
178 D: float | NDArrayFloat | None = field(default_factory=lambda: None)
181def XYZ_to_sCAM(
182 XYZ: Domain100,
183 XYZ_w: Domain100,
184 L_A: ArrayLike,
185 Y_b: ArrayLike,
186 surround: InductionFactors_sCAM = VIEWING_CONDITIONS_sCAM["Average"],
187 discount_illuminant: bool = False,
188) -> Annotated[
189 CAM_Specification_sCAM, (100, 100, 360, 100, 100, 400, 100, 100, 100, 100)
190]:
191 """
192 Compute the *sCAM* colour appearance model correlates from the specified
193 *CIE XYZ* tristimulus values.
195 Parameters
196 ----------
197 XYZ
198 *CIE XYZ* tristimulus values of test sample / stimulus.
199 XYZ_w
200 *CIE XYZ* tristimulus values of reference white.
201 L_A
202 Adapting field *luminance* :math:`L_A` in :math:`cd/m^2`, (often
203 taken to be 20% of the luminance of a white object in the scene).
204 Y_b
205 Luminous factor of background :math:`Y_b` such as
206 :math:`Y_b = 100 \\times L_b / L_w` where :math:`L_w` is the
207 luminance of the light source and :math:`L_b` is the luminance of
208 the background. For viewing images, :math:`Y_b` can be the average
209 :math:`Y` value for the pixels in the entire image, or frequently,
210 a :math:`Y` value of 20, approximating an :math:`L^*` of 50 is
211 used.
212 surround
213 Surround viewing conditions induction factors.
214 discount_illuminant
215 Truth value indicating if the illuminant should be discounted.
217 Returns
218 -------
219 :class:`colour.CAM_Specification_sCAM`
220 *sCAM* colour appearance model specification.
222 Notes
223 -----
224 +---------------------+-----------------------+---------------+
225 | **Domain** | **Scale - Reference** | **Scale - 1** |
226 +=====================+=======================+===============+
227 | ``XYZ`` | 100 | 1 |
228 +---------------------+-----------------------+---------------+
229 | ``XYZ_w`` | 100 | 1 |
230 +---------------------+-----------------------+---------------+
232 +---------------------+-----------------------+---------------+
233 | **Range** | **Scale - Reference** | **Scale - 1** |
234 +=====================+=======================+===============+
235 | ``specification.J`` | 100 | 1 |
236 +---------------------+-----------------------+---------------+
237 | ``specification.C`` | 100 | 1 |
238 +---------------------+-----------------------+---------------+
239 | ``specification.h`` | 360 | 1 |
240 +---------------------+-----------------------+---------------+
241 | ``specification.Q`` | 100 | 1 |
242 +---------------------+-----------------------+---------------+
243 | ``specification.M`` | 100 | 1 |
244 +---------------------+-----------------------+---------------+
245 | ``specification.H`` | 400 | 1 |
246 +---------------------+-----------------------+---------------+
247 | ``specification.HC``| None | None |
248 +---------------------+-----------------------+---------------+
249 | ``specification.V`` | 100 | 1 |
250 +---------------------+-----------------------+---------------+
251 | ``specification.K`` | 100 | 1 |
252 +---------------------+-----------------------+---------------+
253 | ``specification.W`` | 100 | 1 |
254 +---------------------+-----------------------+---------------+
255 | ``specification.D`` | 100 | 1 |
256 +---------------------+-----------------------+---------------+
258 References
259 ----------
260 :cite:`Li2024`
262 Examples
263 --------
264 >>> XYZ = np.array([19.01, 20.00, 21.78])
265 >>> XYZ_w = np.array([95.05, 100.00, 108.88])
266 >>> L_A = 318.31
267 >>> Y_b = 20.0
268 >>> surround = VIEWING_CONDITIONS_sCAM["Average"]
269 >>> XYZ_to_sCAM(XYZ, XYZ_w, L_A, Y_b, surround) # doctest: +ELLIPSIS
270 CAM_Specification_sCAM(J=49.9795668..., C=0.0140531..., h=328.2724924..., \
271Q=195.23024234..., M=0.0050244..., H=363.6013437..., HC=None, V=49.9795727..., \
272K=50.0204272..., W=34.9734327..., D=65.0265672...)
273 """
275 XYZ = to_domain_100(XYZ)
276 XYZ_w = to_domain_100(XYZ_w)
277 L_A = as_float_array(L_A)
278 Y_b = as_float_array(Y_b)
280 Y_w = XYZ_w[..., 1] if XYZ_w.ndim > 1 else XYZ_w[1]
282 with sdiv_mode():
283 z = 1.48 + spow(sdiv(Y_b, Y_w), 0.5)
285 F_L = 0.1710 * spow(L_A, 1 / 3) / (1 - 0.4934 * np.exp(-0.9934 * L_A))
287 with sdiv_mode():
288 L_A_D65 = sdiv(L_A * 100, Y_b)
290 XYZ_w_D65 = TVS_D65_sCAM * L_A_D65[..., None]
292 with domain_range_scale("ignore"):
293 XYZ_D65 = chromatic_adaptation_Li2025(
294 XYZ, XYZ_w, XYZ_w_D65, L_A, surround.F, discount_illuminant
295 )
297 with sdiv_mode():
298 XYZ_D65 = sdiv(XYZ_D65, Y_w[..., None])
300 with domain_range_scale("ignore"):
301 I, C, h = tsplit(sUCS_Iab_to_sUCS_ICh(XYZ_to_sUCS(XYZ_D65))) # noqa: E741
303 I_a = 100 * spow(I / 100, surround.c * z)
305 e_t = 1 + 0.06 * np.cos(np.radians(110 + h))
307 with sdiv_mode():
308 M = (C * spow(F_L, 0.1) * sdiv(1, spow(I_a, 0.27)) * e_t) * surround.F
309 # The original paper contained two inconsistent formulas for calculating Q:
310 # Equation (15) on page 6 uses an exponent of 0.1, while page 10 uses 0.46.
311 # After confirmation with the author, 0.1 is the recommended value.
312 Q = sdiv(2, surround.c) * I_a * spow(F_L, 0.1)
314 H = hue_quadrature(h)
316 V = np.sqrt(I_a**2 + 3 * C**2)
318 K = 100 - V
320 D = 1.3 * np.sqrt((100 - I_a) ** 2 + 1.6 * C**2)
322 W = 100 - D
324 return CAM_Specification_sCAM(
325 J=as_float(from_range_100(I_a)),
326 C=as_float(from_range_100(C)),
327 h=as_float(from_range_degrees(h)),
328 Q=as_float(from_range_100(Q)),
329 M=as_float(from_range_100(M)),
330 H=as_float(from_range_degrees(H, 400)),
331 HC=None,
332 V=as_float(from_range_100(V)),
333 K=as_float(from_range_100(K)),
334 W=as_float(from_range_100(W)),
335 D=as_float(from_range_100(D)),
336 )
339def sCAM_to_XYZ(
340 specification: Annotated[
341 CAM_Specification_sCAM, (100, 100, 360, 100, 100, 400, 100, 100, 100, 100)
342 ],
343 XYZ_w: Domain100,
344 L_A: ArrayLike,
345 Y_b: ArrayLike,
346 surround: InductionFactors_sCAM = VIEWING_CONDITIONS_sCAM["Average"],
347 discount_illuminant: bool = False,
348) -> Range100:
349 """
350 Convert the *sCAM* colour appearance model specification to *CIE XYZ*
351 tristimulus values.
353 Parameters
354 ----------
355 specification
356 *sCAM* colour appearance model specification.
357 XYZ_w
358 *CIE XYZ* tristimulus values of reference white.
359 L_A
360 Adapting field *luminance* :math:`L_A` in :math:`cd/m^2`, (often
361 taken to be 20% of the luminance of a white object in the scene).
362 Y_b
363 Luminous factor of background :math:`Y_b` such as
364 :math:`Y_b = 100 \\times L_b / L_w` where :math:`L_w` is the
365 luminance of the light source and :math:`L_b` is the luminance of
366 the background.
367 surround
368 Surround viewing conditions induction factors.
369 discount_illuminant
370 Truth value indicating if the illuminant should be discounted.
372 Returns
373 -------
374 :class:`numpy.ndarray`
375 *CIE XYZ* tristimulus values.
377 Notes
378 -----
379 +---------------------+-----------------------+---------------+
380 | **Domain** | **Scale - Reference** | **Scale - 1** |
381 +=====================+=======================+===============+
382 | ``specification.J`` | 100 | 1 |
383 +---------------------+-----------------------+---------------+
384 | ``specification.C`` | 100 | 1 |
385 +---------------------+-----------------------+---------------+
386 | ``specification.h`` | 360 | 1 |
387 +---------------------+-----------------------+---------------+
388 | ``specification.M`` | 100 | 1 |
389 +---------------------+-----------------------+---------------+
390 | ``XYZ_w`` | 100 | 1 |
391 +---------------------+-----------------------+---------------+
393 +---------------------+-----------------------+---------------+
394 | **Range** | **Scale - Reference** | **Scale - 1** |
395 +=====================+=======================+===============+
396 | ``XYZ`` | 100 | 1 |
397 +---------------------+-----------------------+---------------+
399 References
400 ----------
401 :cite:`Li2024`
403 Examples
404 --------
405 >>> specification = CAM_Specification_sCAM(
406 ... J=49.979566801800047, C=0.014053112120697316, h=328.2724924444729
407 ... )
408 >>> XYZ_w = np.array([95.05, 100.00, 108.88])
409 >>> L_A = 318.31
410 >>> Y_b = 20
411 >>> sCAM_to_XYZ(specification, XYZ_w, L_A, Y_b) # doctest: +ELLIPSIS
412 array([ 19.01..., 20... , 21.78...])
413 """
415 I_a, C, h, _Q, M, _H, _HC, _V, _K, _W, _D = astuple(specification)
417 I_a = to_domain_100(I_a)
418 C = to_domain_100(C) if not has_only_nan(C) else None
419 h = to_domain_degrees(h)
420 M = to_domain_100(M) if not has_only_nan(M) else None
422 XYZ_w = to_domain_100(XYZ_w)
423 L_A = as_float_array(L_A)
424 Y_b = as_float_array(Y_b)
426 if has_only_nan(I_a) or has_only_nan(h):
427 error = (
428 '"J" and "h" correlates must be defined in '
429 'the "CAM_Specification_sCAM" argument!'
430 )
432 raise ValueError(error)
434 if has_only_nan(C) and has_only_nan(M): # pyright: ignore
435 error = (
436 'Either "C" or "M" correlate must be defined in '
437 'the "CAM_Specification_sCAM" argument!'
438 )
440 raise ValueError(error)
442 Y_w = XYZ_w[..., 1] if XYZ_w.ndim > 1 else XYZ_w[1]
444 with sdiv_mode():
445 z = 1.48 + spow(sdiv(Y_b, Y_w), 0.5)
447 if C is None and M is not None:
448 F_L = 0.1710 * spow(L_A, 1 / 3) / (1 - 0.4934 * np.exp(-0.9934 * L_A))
449 e_t = 1 + 0.06 * np.cos(np.radians(110 + h))
451 with sdiv_mode():
452 C = sdiv(M * spow(I_a, 0.27), spow(F_L, 0.1) * e_t * surround.F)
454 with sdiv_mode():
455 I = 100 * spow(sdiv(I_a, 100), sdiv(1, surround.c * z)) # noqa: E741
457 with domain_range_scale("ignore"):
458 XYZ_D65 = sUCS_to_XYZ(sUCS_ICh_to_sUCS_Iab(tstack([I, C, h])))
460 XYZ_D65 = XYZ_D65 * Y_w[..., None]
462 L_A_D65 = sdiv(L_A * 100, Y_b)
463 XYZ_w_D65 = TVS_D65_sCAM * L_A_D65[..., None]
465 with domain_range_scale("ignore"):
466 XYZ = chromatic_adaptation_Li2025(
467 XYZ_D65,
468 XYZ_w_D65,
469 XYZ_w,
470 L_A,
471 surround.F,
472 discount_illuminant,
473 )
475 return from_range_100(XYZ)
478def hue_quadrature(h: ArrayLike) -> NDArrayFloat:
479 """
480 Compute the *hue* quadrature :math:`H` from the specified *hue* angle
481 :math:`h`.
483 Parameters
484 ----------
485 h
486 *Hue* angle :math:`h` in degrees.
488 Returns
489 -------
490 :class:`numpy.ndarray`
491 *Hue* quadrature :math:`H`.
493 Notes
494 -----
495 +---------------------+-----------------------+---------------+
496 | **Domain** | **Scale - Reference** | **Scale - 1** |
497 +=====================+=======================+===============+
498 | ``h`` | 360 | 1 |
499 +---------------------+-----------------------+---------------+
501 +---------------------+-----------------------+---------------+
502 | **Range** | **Scale - Reference** | **Scale - 1** |
503 +=====================+=======================+===============+
504 | ``H`` | 400 | 1 |
505 +---------------------+-----------------------+---------------+
507 References
508 ----------
509 :cite:`Li2024`
511 Examples
512 --------
513 >>> h = np.array([0, 90, 180, 270])
514 >>> hue_quadrature(h) # doctest: +ELLIPSIS
515 array([ 386.7962881..., 122.2477064..., 229.5474711..., 326.8471216...])
516 """
518 h = as_float_array(h)
519 h_n = as_float_array(h % 360)
521 h_i = HUE_DATA_FOR_HUE_QUADRATURE_sCAM["h_i"]
522 e_i = HUE_DATA_FOR_HUE_QUADRATURE_sCAM["e_i"]
523 H_i = HUE_DATA_FOR_HUE_QUADRATURE_sCAM["H_i"]
525 h_n[np.asarray(np.isnan(h_n))] = 0
526 h_n = np.where(h_n < h_i[0], h_n + 360, h_n)
528 i = np.searchsorted(h_i, h_n, side="right") - 1
529 i = np.clip(i, 0, len(h_i) - 2)
531 h1 = h_i[i]
532 e1 = e_i[i]
533 H1 = H_i[i]
535 h2_idx = (i + 1) % len(h_i)
536 h2 = h_i[h2_idx]
537 e2 = e_i[i + 1]
539 h2 = np.where(h2 < h1, h2 + 360, h2)
541 with sdiv_mode():
542 term1 = sdiv(h_n - h1, e1)
543 term2 = sdiv(h2 - h_n, e2)
545 H = H1 + 100 * sdiv(term1, term1 + term2)
547 return as_float(H)