Source code for colour.quality.cfi2017

"""
CIE 2017 Colour Fidelity Index
==============================

Define the *CIE 2017 Colour Fidelity Index* (CFI) computation objects.

- :class:`colour.quality.ColourRendering_Specification_CIE2017`
- :func:`colour.quality.colour_fidelity_index_CIE2017`

References
----------
-   :cite:`CIETC1-902017` : CIE TC 1-90. (2017). CIE 2017 colour fidelity index
    for accurate scientific use. CIE Central Bureau. ISBN:978-3-902842-61-9
"""

from __future__ import annotations

import os
import typing
from dataclasses import dataclass

import numpy as np

from colour.algebra import Extrapolator, euclidean_distance
from colour.appearance import (
    VIEWING_CONDITIONS_CIECAM02,
    CAM_Specification_CIECAM02,
    XYZ_to_CIECAM02,
)
from colour.colorimetry import (
    MSDS_CMFS,
    CIE_illuminant_D_series,
    MultiSpectralDistributions,
    SpectralDistribution,
    SpectralShape,
    msds_to_XYZ,
    planck_law,
    reshape_msds,
)

if typing.TYPE_CHECKING:
    from colour.hints import ArrayLike, List, Literal, Tuple

from colour.hints import NDArrayFloat, cast
from colour.models import JMh_CIECAM02_to_CAM02UCS, UCS_to_uv, XYZ_to_UCS
from colour.temperature import CCT_to_xy_CIE_D, uv_to_CCT_Ohno2013
from colour.utilities import (
    CACHE_REGISTRY,
    array_namespace,
    as_float,
    as_float_array,
    as_float_scalar,
    as_int_scalar,
    as_ndarray,
    attest,
    is_caching_enabled,
    suppress_warnings,
    tstack,
    usage_warning,
    xp_as_float_array,
    xp_average,
    xp_matrix_transpose,
)

__author__ = "Colour Developers"
__copyright__ = "Copyright 2013 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__ = [
    "SPECTRAL_SHAPE_CIE2017",
    "ROOT_RESOURCES_CIE2017",
    "DataColorimetry_TCS_CIE2017",
    "ColourRendering_Specification_CIE2017",
    "colour_fidelity_index_CIE2017",
    "load_TCS_CIE2017",
    "tcs_colorimetry_data",
    "delta_E_to_R_f",
]

SPECTRAL_SHAPE_CIE2017: SpectralShape = SpectralShape(380, 780, 1)
"""
Spectral shape for *CIE 2017 Colour Fidelity Index* (CFI)
standard.
"""

ROOT_RESOURCES_CIE2017: str = os.path.join(os.path.dirname(__file__), "datasets")
"""*CIE 2017 Colour Fidelity Index* resources directory."""

_CACHE_TCS_CIE2017: dict = CACHE_REGISTRY.register_cache(
    f"{__name__}._CACHE_TCS_CIE2017"
)


@dataclass
class DataColorimetry_TCS_CIE2017:
    """
    Store colorimetry data for *test colour samples* used in CIE 2017
    colour fidelity calculations.

    This dataclass encapsulates the colorimetric properties of test colour
    samples as specified by CIE 2017, including their tristimulus values,
    colour appearance model specifications, and perceptual colour
    coordinates in both cylindrical and rectangular representations.

    Attributes
    ----------
    name
        Identifier(s) for the test colour sample(s).
    XYZ
        CIE XYZ tristimulus values of the test colour samples.
    CAM
        CIECAM02 colour appearance model specification containing the
        complete appearance correlates.
    JMh
        Perceptual colour coordinates in cylindrical representation with
        *lightness* (J), *colourfulness* (M), and *hue angle* (h).
    Jpapbp
        Perceptual colour coordinates in rectangular representation with
        *lightness* (J) and opponent colour dimensions (a', b').
    """

    name: str | list[str]
    XYZ: NDArrayFloat
    CAM: CAM_Specification_CIECAM02
    JMh: NDArrayFloat
    Jpapbp: NDArrayFloat


[docs] @dataclass class ColourRendering_Specification_CIE2017: """ Define the *CIE 2017 Colour Fidelity Index* (CFI) colour quality specification. Parameters ---------- name Name of the test spectral distribution. sd_reference Spectral distribution of the reference illuminant. R_f *CIE 2017 Colour Fidelity Index* (CFI) :math:`R_f`. R_s Individual *colour fidelity indexes* data for each sample. CCT Correlated colour temperature :math:`T_{cp}`. D_uv Distance from the Planckian locus :math:`\\Delta_{uv}`. colorimetry_data Colorimetry data for the test and reference computations. delta_E_s Colour shifts of samples. """ name: str sd_reference: SpectralDistribution R_f: float R_s: NDArrayFloat CCT: float D_uv: float colorimetry_data: Tuple[DataColorimetry_TCS_CIE2017, DataColorimetry_TCS_CIE2017] delta_E_s: NDArrayFloat
@typing.overload def colour_fidelity_index_CIE2017( sd_test: SpectralDistribution, additional_data: Literal[False] = False ) -> float: ... @typing.overload def colour_fidelity_index_CIE2017( sd_test: SpectralDistribution, additional_data: Literal[True] ) -> ColourRendering_Specification_CIE2017: ... @typing.overload def colour_fidelity_index_CIE2017( sd_test: MultiSpectralDistributions, additional_data: Literal[False] = False, ) -> NDArrayFloat: ...
[docs] def colour_fidelity_index_CIE2017( sd_test: SpectralDistribution | MultiSpectralDistributions, additional_data: bool = False, ) -> float | NDArrayFloat | ColourRendering_Specification_CIE2017: """ Compute the *CIE 2017 Colour Fidelity Index* (CFI) :math:`R_f` of the specified spectral distribution. Parameters ---------- sd_test Test spectral distribution. A :class:`colour.MultiSpectralDistributions` of ``N`` test illuminants is also accepted, in which case ``additional_data`` must be ``False`` and the return value is a :class:`numpy.ndarray` of ``N`` :math:`R_f` values. additional_data Whether to output additional data. Returns ------- :class:`float`, :class:`numpy.ndarray` or \ :class:`colour.quality.ColourRendering_Specification_CIE2017` *CIE 2017 Colour Fidelity Index* (CFI) :math:`R_f`. References ---------- :cite:`CIETC1-902017` Examples -------- >>> from colour.colorimetry import SDS_ILLUMINANTS >>> sd = SDS_ILLUMINANTS["FL2"] >>> colour_fidelity_index_CIE2017(sd) # doctest: +ELLIPSIS np.float64(70.1208244...) """ is_msds = isinstance(sd_test, MultiSpectralDistributions) if is_msds and additional_data: error = ( '"additional_data=True" is not supported when "sd_test" is a ' '"MultiSpectralDistributions" instance.' ) raise NotImplementedError(error) if sd_test.shape.interval > 5: error = ( "Test spectral distribution interval is greater than " "5nm which is the maximum recommended value " 'for computing the "CIE 2017 Colour Fidelity Index"!' ) raise ValueError(error) shape = SpectralShape( SPECTRAL_SHAPE_CIE2017.start, SPECTRAL_SHAPE_CIE2017.end, sd_test.shape.interval, ) if sd_test.shape.start > 380 or sd_test.shape.end < 780: usage_warning( "Test spectral distribution shape does not span the " "recommended 380-780nm range, missing values will be " "filled with zeros!" ) # NOTE: "CIE 2017 Colour Fidelity Index" standard recommends filling # missing values with zeros. sd_test = sd_test.copy() sd_test.extrapolator = Extrapolator sd_test.extrapolator_kwargs = { "method": "constant", "left": 0, "right": 0, } sd_test.align(shape=shape) if sd_test.shape.boundaries != shape.boundaries: sd_test.trim(shape) # NOTE: All computations except CCT calculation use the # "CIE 1964 10 Degree Standard Observer". cmfs_10 = reshape_msds( MSDS_CMFS["CIE 1964 10 Degree Standard Observer"], shape, copy=False ) sds_tcs = load_TCS_CIE2017(shape) sd_test_values = sd_test.values xp = array_namespace(sds_tcs.values, sd_test_values) if is_msds: test_values = xp_matrix_transpose( xp_as_float_array(sd_test_values, xp=xp), xp=xp ) else: test_values = xp_as_float_array(sd_test_values, xp=xp)[None, :] XYZ_test_2deg = msds_to_XYZ(test_values, method="Integration", shape=shape) CCT_Duv = uv_to_CCT_Ohno2013( UCS_to_uv(XYZ_to_UCS(XYZ_test_2deg)), start=1000, end=25000 ) CCT = CCT_Duv[..., 0] D_uv = CCT_Duv[..., 1] # ``CIE 2017 CFI`` 3-way reference: Y-normalised Planckian / daylight # mixture clipped over the ``[4000, 5000]`` K transition window # (:cite:`CIETC1-902017`, Section 4.2). ``planck_law`` squeezes its # output, so a single-CCT batch collapses to 1-D; the sample axis is # reinstated below. This vectorised mixture is the single source of the # reference illuminant; the ``additional_data`` branch wraps the # corresponding ``ref_values`` row in a spectral distribution. planckian = planck_law(shape.wavelengths * 1e-9, CCT) * 1e-9 planckian_values = ( planckian[None, :] if planckian.ndim == 1 else xp_matrix_transpose(planckian, xp=xp) ) # ``CCT_to_xy_CIE_D`` warns for any sample outside ``[4000, 25000]`` K # (matching the suppression in :mod:`colour.quality.cri` / # :mod:`colour.quality.cqs`); ``m = 0`` nulls the extrapolated daylight # so the leaked warning is spurious here. with suppress_warnings(colour_usage_warnings=True): daylight = CIE_illuminant_D_series(CCT_to_xy_CIE_D(CCT), shape=shape) daylight_values = ( daylight[None, :] if daylight.ndim == 1 else xp_matrix_transpose(daylight, xp=xp) ) Y_planckian = msds_to_XYZ(planckian_values, method="Integration", shape=shape)[ ..., 1:2 ] Y_daylight = msds_to_XYZ(daylight_values, method="Integration", shape=shape)[ ..., 1:2 ] m = xp.clip((CCT - 4000) / 1000, 0, 1)[..., None] ref_values = (1 - m) * (planckian_values / Y_planckian) + m * ( daylight_values / Y_daylight ) irradiance_values = xp.stack([test_values, ref_values]) XYZ_t = msds_to_XYZ(irradiance_values, cmfs_10, method="Integration", shape=shape) k = 100 / XYZ_t[..., 1:2] XYZ_w = k * XYZ_t irradiance_values = irradiance_values * k XYZ, specification, JMh, Jpapbp = _tcs_colorimetry_data( irradiance_values, XYZ_w, sds_tcs, cmfs_10 ) delta_E_s = euclidean_distance(Jpapbp[0], Jpapbp[1]) R_s = delta_E_to_R_f(delta_E_s) R_f = delta_E_to_R_f(xp_average(delta_E_s, axis=-1, xp=xp)) if is_msds: return R_f R_f_scalar = as_float_scalar(R_f[0]) if not additional_data: return R_f_scalar sd_reference = SpectralDistribution( as_ndarray(ref_values[0]), shape.wavelengths, name=f"{int(CCT[0])}K CIE 2017 Reference Illuminant", ) # Drop the size-1 batch axis from the rank-3 outputs to recover the # original (n_irradiances, n_TCS, *) layout. XYZ = XYZ[:, 0] JMh = JMh[:, 0] Jpapbp = Jpapbp[:, 0] specification = CAM_Specification_CIECAM02( **{ name: (value[:, 0] if value is not None else None) for name, value in specification } ) # ``as_float_array`` materialises the dataclass to *NumPy* via # ``__array__``, so the transpose stays on *NumPy* regardless of ``xp``. specification = np.transpose(as_float_array(specification), (0, 2, 1)) specifications = [CAM_Specification_CIECAM02(*t) for t in specification] test_tcs_colorimetry_data = DataColorimetry_TCS_CIE2017( sds_tcs.display_labels, XYZ[0], specifications[0], JMh[0], Jpapbp[0], ) reference_tcs_colorimetry_data = DataColorimetry_TCS_CIE2017( sds_tcs.display_labels, XYZ[1], specifications[1], JMh[1], Jpapbp[1], ) return ColourRendering_Specification_CIE2017( sd_test.name, sd_reference, R_f_scalar, R_s[0], as_float_scalar(CCT[0]), as_float_scalar(D_uv[0]), (test_tcs_colorimetry_data, reference_tcs_colorimetry_data), delta_E_s[0], )
def load_TCS_CIE2017(shape: SpectralShape) -> MultiSpectralDistributions: """ Load the *CIE 2017 Test Colour Samples* dataset appropriate for the specified spectral shape. The datasets are cached and will not be loaded again on subsequent calls to this definition. Parameters ---------- shape Spectral shape of the tested illuminant. Returns ------- :class:`colour.MultiSpectralDistributions` *CIE 2017 Test Colour Samples* dataset. Examples -------- >>> sds_tcs = load_TCS_CIE2017(SpectralShape(380, 780, 5)) >>> len(sds_tcs.labels) 99 """ global _CACHE_TCS_CIE2017 # noqa: PLW0602 interval = shape.interval attest( interval in (1, 5), "Spectral shape interval must be either 1nm or 5nm!", ) filename = f"tcs_cfi2017_{as_int_scalar(interval)}_nm.csv.gz" if is_caching_enabled() and filename in _CACHE_TCS_CIE2017: return _CACHE_TCS_CIE2017[filename] data = np.genfromtxt( str(os.path.join(ROOT_RESOURCES_CIE2017, filename)), delimiter="," ) labels = [f"TCS{i} (CIE 2017)" for i in range(99)] tcs = MultiSpectralDistributions(data[:, 1:], data[:, 0], labels) _CACHE_TCS_CIE2017[filename] = tcs return tcs def _tcs_colorimetry_data( irradiance_values: NDArrayFloat, XYZ_w: NDArrayFloat, sds_tcs: MultiSpectralDistributions, cmfs: MultiSpectralDistributions, ) -> Tuple[NDArrayFloat, CAM_Specification_CIECAM02, NDArrayFloat, NDArrayFloat]: """ Compute the *test colour samples* colorimetry arrays under the specified irradiance(s) and reference white point(s) for the *CIE 2017 Colour Fidelity Index* (CFI) computations. Parameters ---------- irradiance_values Per-illuminant normalised irradiance spectral values of shape ``(..., n_wavelengths)``. XYZ_w Per-illuminant *CIE XYZ* tristimulus values of shape ``(..., 3)``. sds_tcs *Test colour samples* spectral reflectance distributions. cmfs Standard observer colour matching functions. Returns ------- :class:`tuple` ``(XYZ, specification, JMh, Jpapbp)`` arrays, each with leading shape ``(..., n_test_colour_samples)``. """ sds_tcs_values_raw = sds_tcs.values xp = array_namespace(irradiance_values, sds_tcs_values_raw) sds_tcs_values = xp_as_float_array( sds_tcs_values_raw, xp=xp, like=irradiance_values ) sds_tcs_t = ( xp_matrix_transpose(sds_tcs_values, xp=xp) * irradiance_values[..., None, :] ) XYZ = msds_to_XYZ( sds_tcs_t, cmfs, method="Integration", shape=sds_tcs.shape, ) specification = XYZ_to_CIECAM02( XYZ, XYZ_w[..., None, :], 100, # L_A 20, # Y_b VIEWING_CONDITIONS_CIECAM02["Average"], discount_illuminant=True, compute_H=False, ) JMh = tstack( [ cast("NDArrayFloat", specification.J), cast("NDArrayFloat", specification.M), cast("NDArrayFloat", specification.h), ] ) Jpapbp = JMh_CIECAM02_to_CAM02UCS(JMh) return XYZ, specification, JMh, Jpapbp def tcs_colorimetry_data( sd_irradiance: SpectralDistribution | List[SpectralDistribution], sds_tcs: MultiSpectralDistributions, cmfs: MultiSpectralDistributions, ) -> Tuple[DataColorimetry_TCS_CIE2017, ...]: """ Compute the *test colour samples* colorimetry data under the specified test light source or reference illuminant spectral distribution for the *CIE 2017 Colour Fidelity Index* (CFI) computations. Parameters ---------- sd_irradiance Test light source or reference illuminant spectral distribution, i.e., the irradiance emitter. sds_tcs *Test colour samples* spectral reflectance distributions. cmfs Standard observer colour matching functions. Returns ------- :class:`tuple` *Test colour samples* colorimetry data under the specified test light source or reference illuminant spectral distribution. Examples -------- >>> from colour.colorimetry import SDS_ILLUMINANTS >>> sd = SDS_ILLUMINANTS["FL2"] >>> shape = SpectralShape(380, 780, 5) >>> cmfs = MSDS_CMFS["CIE 1964 10 Degree Standard Observer"].copy().align(shape) >>> test_tcs_colorimetry_data = tcs_colorimetry_data( ... sd, load_TCS_CIE2017(shape), cmfs ... ) >>> len(test_tcs_colorimetry_data) 1 """ if isinstance(sd_irradiance, SpectralDistribution): sd_irradiance = [sd_irradiance] xp = array_namespace(sds_tcs.values, sd_irradiance[0].values) irradiance_values = xp.stack( [xp_as_float_array(sd.values, xp=xp) for sd in sd_irradiance] ) XYZ_t = msds_to_XYZ( irradiance_values, cmfs, method="Integration", shape=sd_irradiance[0].shape, ) k = 100 / XYZ_t[..., 1:2] XYZ_w = k * XYZ_t irradiance_values = irradiance_values * k XYZ, specification, JMh, Jpapbp = _tcs_colorimetry_data( irradiance_values, XYZ_w, sds_tcs, cmfs ) # ``as_float_array`` materialises the dataclass to *NumPy* via # ``__array__``, so the transpose stays on *NumPy* regardless of ``xp``. specification = np.transpose(as_float_array(specification), (0, 2, 1)) specifications = [CAM_Specification_CIECAM02(*t) for t in specification] return tuple( [ DataColorimetry_TCS_CIE2017( sds_tcs.display_labels, XYZ[sd_idx], specifications[sd_idx], JMh[sd_idx], Jpapbp[sd_idx], ) for sd_idx in range(len(sd_irradiance)) ] ) def delta_E_to_R_f(delta_E: ArrayLike) -> NDArrayFloat: """ Convert colour-appearance difference to *CIE 2017 Colour Fidelity Index* (CFI) :math:`R_f` value. Parameters ---------- delta_E Euclidean distance between two colours in *CAM02-UCS* colourspace. Returns ------- :class:`numpy.ndarray` Corresponding *CIE 2017 Colour Fidelity Index* (CFI) :math:`R_f` value. """ delta_E = as_float_array(delta_E) xp = array_namespace(delta_E) c_f = 6.73 return as_float(10 * xp.log1p(xp.exp((100 - c_f * delta_E) / 10)))