Source code for colour.appearance.scam

"""
sCAM Colour Appearance Model
============================

Define the *sCAM* colour appearance model for predicting perceptual colour
attributes under varying viewing conditions.

-   :class:`colour.appearance.InductionFactors_sCAM`
-   :attr:`colour.VIEWING_CONDITIONS_sCAM`
-   :class:`colour.CAM_Specification_sCAM`
-   :func:`colour.XYZ_to_sCAM`
-   :func:`colour.sCAM_to_XYZ`

The *sCAM* (Simple Colour Appearance Model) is based on the *sUCS* (Simple
Uniform Colour Space).

References
----------
-   :cite:`Li2024` : Li, M., & Luo, M. R. (2024). Simple color appearance model
    (sCAM) based on simple uniform color space (sUCS). Optics Express, 32(3),
    3100. doi:10.1364/OE.510196
"""

from __future__ import annotations

from dataclasses import astuple, dataclass, field

import numpy as np

from colour.adaptation import chromatic_adaptation_Li2025
from colour.algebra import sdiv, sdiv_mode, spow
from colour.hints import (  # noqa: TC001
    Annotated,
    ArrayLike,
    Domain100,
    NDArrayFloat,
    Range100,
)
from colour.models.sucs import (
    XYZ_to_sUCS,
    sUCS_Iab_to_sUCS_ICh,
    sUCS_ICh_to_sUCS_Iab,
    sUCS_to_XYZ,
)
from colour.utilities import (
    CanonicalMapping,
    MixinDataclassArithmetic,
    MixinDataclassIterable,
    as_float,
    as_float_array,
    domain_range_scale,
    from_range_100,
    from_range_degrees,
    has_only_nan,
    to_domain_100,
    to_domain_degrees,
    tsplit,
    tstack,
)

__author__ = "Colour Developers"
__copyright__ = "Copyright 2024 Colour Developers"
__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
__maintainer__ = "Colour Developers"
__email__ = "colour-developers@colour-science.org"
__status__ = "Production"

__all__ = [
    "TVS_D65_sCAM",
    "HUE_DATA_FOR_HUE_QUADRATURE_sCAM",
    "InductionFactors_sCAM",
    "VIEWING_CONDITIONS_sCAM",
    "CAM_Specification_sCAM",
    "XYZ_to_sCAM",
    "sCAM_to_XYZ",
    "hue_quadrature",
]

TVS_D65_sCAM = np.array([0.95047, 1.00000, 1.08883])
"""*CIE XYZ* tristimulus values of *CIE Standard Illuminant D65* for *sCAM*."""

HUE_DATA_FOR_HUE_QUADRATURE_sCAM: dict = {
    "h_i": np.array([15.6, 80.3, 157.8, 219.7, 376.6]),
    "e_i": np.array([0.7, 0.6, 1.2, 0.9, 0.7]),
    "H_i": np.array([0.0, 100.0, 200.0, 300.0, 400.0]),
}
"""Hue quadrature data for *sCAM* colour appearance model."""


[docs] @dataclass(frozen=True) class InductionFactors_sCAM(MixinDataclassIterable): """ Define the *sCAM* colour appearance model induction factors. Parameters ---------- F Maximum degree of adaptation :math:`F`. c Exponential non-linearity :math:`c`. Fm Factor for colourfulness :math:`F_m`. References ---------- :cite:`Li2024` """ F: float c: float Fm: float
VIEWING_CONDITIONS_sCAM: CanonicalMapping = CanonicalMapping( { "Average": InductionFactors_sCAM(F=1.0, c=0.52, Fm=1.0), "Dim": InductionFactors_sCAM(F=0.9, c=0.50, Fm=0.95), "Dark": InductionFactors_sCAM(F=0.8, c=0.39, Fm=0.85), } ) VIEWING_CONDITIONS_sCAM.__doc__ = """ Define the reference *sCAM* colour appearance model viewing conditions. Provide standardized surround conditions (*Average*, *Dim*, *Dark*) with their corresponding induction factors that characterize chromatic adaptation and perceptual non-linearities under different viewing environments. """
[docs] @dataclass class CAM_Specification_sCAM(MixinDataclassArithmetic): """ Define the specification for the *sCAM* colour appearance model. Parameters ---------- J Correlate of *lightness* :math:`J`. C Correlate of *chroma* :math:`C`. h *Hue* angle :math:`h` in degrees. Q Correlate of *brightness* :math:`Q`. M Correlate of *colourfulness* :math:`M`. H *Hue* :math:`h` composition :math:`H`. HC *Hue* :math:`h` composition :math:`H^C` (currently not implemented). V Correlate of *vividness* :math:`V`. K Correlate of *blackness* :math:`K`. W Correlate of *whiteness* :math:`W`. D Correlate of *depth* :math:`D`. References ---------- :cite:`Li2024` """ J: float | NDArrayFloat | None = field(default_factory=lambda: None) C: float | NDArrayFloat | None = field(default_factory=lambda: None) h: float | NDArrayFloat | None = field(default_factory=lambda: None) Q: float | NDArrayFloat | None = field(default_factory=lambda: None) M: float | NDArrayFloat | None = field(default_factory=lambda: None) H: float | NDArrayFloat | None = field(default_factory=lambda: None) HC: float | NDArrayFloat | None = field(default_factory=lambda: None) V: float | NDArrayFloat | None = field(default_factory=lambda: None) K: float | NDArrayFloat | None = field(default_factory=lambda: None) W: float | NDArrayFloat | None = field(default_factory=lambda: None) D: float | NDArrayFloat | None = field(default_factory=lambda: None)
[docs] def XYZ_to_sCAM( XYZ: Domain100, XYZ_w: Domain100, L_A: ArrayLike, Y_b: ArrayLike, surround: InductionFactors_sCAM = VIEWING_CONDITIONS_sCAM["Average"], discount_illuminant: bool = False, ) -> Annotated[ CAM_Specification_sCAM, (100, 100, 360, 100, 100, 400, 100, 100, 100, 100) ]: """ Compute the *sCAM* colour appearance model correlates from the specified *CIE XYZ* tristimulus values. Parameters ---------- XYZ *CIE XYZ* tristimulus values of test sample / stimulus. XYZ_w *CIE XYZ* tristimulus values of reference white. L_A Adapting field *luminance* :math:`L_A` in :math:`cd/m^2`, (often taken to be 20% of the luminance of a white object in the scene). Y_b Luminous factor of background :math:`Y_b` such as :math:`Y_b = 100 \\times L_b / L_w` where :math:`L_w` is the luminance of the light source and :math:`L_b` is the luminance of the background. For viewing images, :math:`Y_b` can be the average :math:`Y` value for the pixels in the entire image, or frequently, a :math:`Y` value of 20, approximating an :math:`L^*` of 50 is used. surround Surround viewing conditions induction factors. discount_illuminant Truth value indicating if the illuminant should be discounted. Returns ------- :class:`colour.CAM_Specification_sCAM` *sCAM* colour appearance model specification. Notes ----- +---------------------+-----------------------+---------------+ | **Domain** | **Scale - Reference** | **Scale - 1** | +=====================+=======================+===============+ | ``XYZ`` | 100 | 1 | +---------------------+-----------------------+---------------+ | ``XYZ_w`` | 100 | 1 | +---------------------+-----------------------+---------------+ +---------------------+-----------------------+---------------+ | **Range** | **Scale - Reference** | **Scale - 1** | +=====================+=======================+===============+ | ``specification.J`` | 100 | 1 | +---------------------+-----------------------+---------------+ | ``specification.C`` | 100 | 1 | +---------------------+-----------------------+---------------+ | ``specification.h`` | 360 | 1 | +---------------------+-----------------------+---------------+ | ``specification.Q`` | 100 | 1 | +---------------------+-----------------------+---------------+ | ``specification.M`` | 100 | 1 | +---------------------+-----------------------+---------------+ | ``specification.H`` | 400 | 1 | +---------------------+-----------------------+---------------+ | ``specification.HC``| None | None | +---------------------+-----------------------+---------------+ | ``specification.V`` | 100 | 1 | +---------------------+-----------------------+---------------+ | ``specification.K`` | 100 | 1 | +---------------------+-----------------------+---------------+ | ``specification.W`` | 100 | 1 | +---------------------+-----------------------+---------------+ | ``specification.D`` | 100 | 1 | +---------------------+-----------------------+---------------+ References ---------- :cite:`Li2024` Examples -------- >>> XYZ = np.array([19.01, 20.00, 21.78]) >>> XYZ_w = np.array([95.05, 100.00, 108.88]) >>> L_A = 318.31 >>> Y_b = 20.0 >>> surround = VIEWING_CONDITIONS_sCAM["Average"] >>> XYZ_to_sCAM(XYZ, XYZ_w, L_A, Y_b, surround) # doctest: +ELLIPSIS CAM_Specification_sCAM(J=49.9795668..., C=0.0140531..., h=328.2724924..., \ Q=195.23024234..., M=0.0050244..., H=363.6013437..., HC=None, V=49.9795727..., \ K=50.0204272..., W=34.9734327..., D=65.0265672...) """ XYZ = to_domain_100(XYZ) XYZ_w = to_domain_100(XYZ_w) L_A = as_float_array(L_A) Y_b = as_float_array(Y_b) Y_w = XYZ_w[..., 1] if XYZ_w.ndim > 1 else XYZ_w[1] with sdiv_mode(): z = 1.48 + spow(sdiv(Y_b, Y_w), 0.5) F_L = 0.1710 * spow(L_A, 1 / 3) / (1 - 0.4934 * np.exp(-0.9934 * L_A)) with sdiv_mode(): L_A_D65 = sdiv(L_A * 100, Y_b) XYZ_w_D65 = TVS_D65_sCAM * L_A_D65[..., None] with domain_range_scale("ignore"): XYZ_D65 = chromatic_adaptation_Li2025( XYZ, XYZ_w, XYZ_w_D65, L_A, surround.F, discount_illuminant ) with sdiv_mode(): XYZ_D65 = sdiv(XYZ_D65, Y_w[..., None]) with domain_range_scale("ignore"): I, C, h = tsplit(sUCS_Iab_to_sUCS_ICh(XYZ_to_sUCS(XYZ_D65))) # noqa: E741 I_a = 100 * spow(I / 100, surround.c * z) e_t = 1 + 0.06 * np.cos(np.radians(110 + h)) with sdiv_mode(): M = (C * spow(F_L, 0.1) * sdiv(1, spow(I_a, 0.27)) * e_t) * surround.F # The original paper contained two inconsistent formulas for calculating Q: # Equation (15) on page 6 uses an exponent of 0.1, while page 10 uses 0.46. # After confirmation with the author, 0.1 is the recommended value. Q = sdiv(2, surround.c) * I_a * spow(F_L, 0.1) H = hue_quadrature(h) V = np.sqrt(I_a**2 + 3 * C**2) K = 100 - V D = 1.3 * np.sqrt((100 - I_a) ** 2 + 1.6 * C**2) W = 100 - D return CAM_Specification_sCAM( J=as_float(from_range_100(I_a)), C=as_float(from_range_100(C)), h=as_float(from_range_degrees(h)), Q=as_float(from_range_100(Q)), M=as_float(from_range_100(M)), H=as_float(from_range_degrees(H, 400)), HC=None, V=as_float(from_range_100(V)), K=as_float(from_range_100(K)), W=as_float(from_range_100(W)), D=as_float(from_range_100(D)), )
[docs] def sCAM_to_XYZ( specification: Annotated[ CAM_Specification_sCAM, (100, 100, 360, 100, 100, 400, 100, 100, 100, 100) ], XYZ_w: Domain100, L_A: ArrayLike, Y_b: ArrayLike, surround: InductionFactors_sCAM = VIEWING_CONDITIONS_sCAM["Average"], discount_illuminant: bool = False, ) -> Range100: """ Convert the *sCAM* colour appearance model specification to *CIE XYZ* tristimulus values. Parameters ---------- specification *sCAM* colour appearance model specification. XYZ_w *CIE XYZ* tristimulus values of reference white. L_A Adapting field *luminance* :math:`L_A` in :math:`cd/m^2`, (often taken to be 20% of the luminance of a white object in the scene). Y_b Luminous factor of background :math:`Y_b` such as :math:`Y_b = 100 \\times L_b / L_w` where :math:`L_w` is the luminance of the light source and :math:`L_b` is the luminance of the background. surround Surround viewing conditions induction factors. discount_illuminant Truth value indicating if the illuminant should be discounted. Returns ------- :class:`numpy.ndarray` *CIE XYZ* tristimulus values. Notes ----- +---------------------+-----------------------+---------------+ | **Domain** | **Scale - Reference** | **Scale - 1** | +=====================+=======================+===============+ | ``specification.J`` | 100 | 1 | +---------------------+-----------------------+---------------+ | ``specification.C`` | 100 | 1 | +---------------------+-----------------------+---------------+ | ``specification.h`` | 360 | 1 | +---------------------+-----------------------+---------------+ | ``specification.M`` | 100 | 1 | +---------------------+-----------------------+---------------+ | ``XYZ_w`` | 100 | 1 | +---------------------+-----------------------+---------------+ +---------------------+-----------------------+---------------+ | **Range** | **Scale - Reference** | **Scale - 1** | +=====================+=======================+===============+ | ``XYZ`` | 100 | 1 | +---------------------+-----------------------+---------------+ References ---------- :cite:`Li2024` Examples -------- >>> specification = CAM_Specification_sCAM( ... J=49.979566801800047, C=0.014053112120697316, h=328.2724924444729 ... ) >>> XYZ_w = np.array([95.05, 100.00, 108.88]) >>> L_A = 318.31 >>> Y_b = 20 >>> sCAM_to_XYZ(specification, XYZ_w, L_A, Y_b) # doctest: +ELLIPSIS array([ 19.01..., 20... , 21.78...]) """ I_a, C, h, _Q, M, _H, _HC, _V, _K, _W, _D = astuple(specification) I_a = to_domain_100(I_a) C = to_domain_100(C) if not has_only_nan(C) else None h = to_domain_degrees(h) M = to_domain_100(M) if not has_only_nan(M) else None XYZ_w = to_domain_100(XYZ_w) L_A = as_float_array(L_A) Y_b = as_float_array(Y_b) if has_only_nan(I_a) or has_only_nan(h): error = ( '"J" and "h" correlates must be defined in ' 'the "CAM_Specification_sCAM" argument!' ) raise ValueError(error) if has_only_nan(C) and has_only_nan(M): # pyright: ignore error = ( 'Either "C" or "M" correlate must be defined in ' 'the "CAM_Specification_sCAM" argument!' ) raise ValueError(error) Y_w = XYZ_w[..., 1] if XYZ_w.ndim > 1 else XYZ_w[1] with sdiv_mode(): z = 1.48 + spow(sdiv(Y_b, Y_w), 0.5) if C is None and M is not None: F_L = 0.1710 * spow(L_A, 1 / 3) / (1 - 0.4934 * np.exp(-0.9934 * L_A)) e_t = 1 + 0.06 * np.cos(np.radians(110 + h)) with sdiv_mode(): C = sdiv(M * spow(I_a, 0.27), spow(F_L, 0.1) * e_t * surround.F) with sdiv_mode(): I = 100 * spow(sdiv(I_a, 100), sdiv(1, surround.c * z)) # noqa: E741 with domain_range_scale("ignore"): XYZ_D65 = sUCS_to_XYZ(sUCS_ICh_to_sUCS_Iab(tstack([I, C, h]))) # type: ignore[arg-type] XYZ_D65 = XYZ_D65 * Y_w[..., None] L_A_D65 = sdiv(L_A * 100, Y_b) XYZ_w_D65 = TVS_D65_sCAM * L_A_D65[..., None] with domain_range_scale("ignore"): XYZ = chromatic_adaptation_Li2025( XYZ_D65, XYZ_w_D65, XYZ_w, L_A, surround.F, discount_illuminant, ) return from_range_100(XYZ)
def hue_quadrature(h: ArrayLike) -> NDArrayFloat: """ Compute the *hue* quadrature :math:`H` from the specified *hue* angle :math:`h`. Parameters ---------- h *Hue* angle :math:`h` in degrees. Returns ------- :class:`numpy.ndarray` *Hue* quadrature :math:`H`. Notes ----- +---------------------+-----------------------+---------------+ | **Domain** | **Scale - Reference** | **Scale - 1** | +=====================+=======================+===============+ | ``h`` | 360 | 1 | +---------------------+-----------------------+---------------+ +---------------------+-----------------------+---------------+ | **Range** | **Scale - Reference** | **Scale - 1** | +=====================+=======================+===============+ | ``H`` | 400 | 1 | +---------------------+-----------------------+---------------+ References ---------- :cite:`Li2024` Examples -------- >>> h = np.array([0, 90, 180, 270]) >>> hue_quadrature(h) # doctest: +ELLIPSIS array([ 386.7962881..., 122.2477064..., 229.5474711..., 326.8471216...]) """ h = as_float_array(h) h_n = as_float_array(h % 360) h_i = HUE_DATA_FOR_HUE_QUADRATURE_sCAM["h_i"] e_i = HUE_DATA_FOR_HUE_QUADRATURE_sCAM["e_i"] H_i = HUE_DATA_FOR_HUE_QUADRATURE_sCAM["H_i"] h_n[np.asarray(np.isnan(h_n))] = 0 h_n = np.where(h_n < h_i[0], h_n + 360, h_n) i = np.searchsorted(h_i, h_n, side="right") - 1 i = np.clip(i, 0, len(h_i) - 2) h1 = h_i[i] e1 = e_i[i] H1 = H_i[i] h2_idx = (i + 1) % len(h_i) h2 = h_i[h2_idx] e2 = e_i[i + 1] h2 = np.where(h2 < h1, h2 + 360, h2) with sdiv_mode(): term1 = sdiv(h_n - h1, e1) term2 = sdiv(h2 - h_n, e2) H = H1 + 100 * sdiv(term1, term1 + term2) return as_float(H)