"""
CIE 2017 Colour Fidelity Index
==============================
Defines 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 numpy as np
import os
from dataclasses import dataclass
from colour.algebra import Extrapolator, euclidean_distance, linstep_function
from colour.appearance import (
CAM_Specification_CIECAM02,
XYZ_to_CIECAM02,
VIEWING_CONDITIONS_CIECAM02,
)
from colour.colorimetry import (
MSDS_CMFS,
MultiSpectralDistributions,
SpectralShape,
SpectralDistribution,
sd_to_XYZ,
sd_blackbody,
reshape_msds,
sd_ones,
sd_CIE_illuminant_D_series,
)
from colour.hints import ArrayLike, NDArrayFloat, Tuple, cast
from colour.models import XYZ_to_UCS, UCS_to_uv, JMh_CIECAM02_to_CAM02UCS
from colour.temperature import uv_to_CCT_Ohno2013, CCT_to_xy_CIE_D
from colour.utilities import (
CACHE_REGISTRY,
as_float,
as_float_array,
as_float_scalar,
as_int_scalar,
attest,
tsplit,
tstack,
usage_warning,
)
__author__ = "Colour Developers"
__copyright__ = "Copyright 2013 Colour Developers"
__license__ = "New BSD License - 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",
"CCT_reference_illuminant",
"sd_reference_illuminant",
"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:
"""Define the class storing *test colour samples* colorimetry data."""
name: 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[
Tuple[DataColorimetry_TCS_CIE2017, ...],
Tuple[DataColorimetry_TCS_CIE2017, ...],
]
delta_E_s: NDArrayFloat
[docs]def colour_fidelity_index_CIE2017(
sd_test: SpectralDistribution, additional_data: bool = False
) -> float | ColourRendering_Specification_CIE2017:
"""
Return the *CIE 2017 Colour Fidelity Index* (CFI) :math:`R_f` of given
spectral distribution.
Parameters
----------
sd_test
Test spectral distribution.
additional_data
Whether to output additional data.
Returns
-------
:class:`float` 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
70.1208254...
"""
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,
}
if sd_test.shape.interval > 5:
raise ValueError(
"Test spectral distribution interval is greater than"
"5nm which is the maximum recommended value "
'for computing the "CIE 2017 Colour Fidelity Index"!'
)
shape = SpectralShape(
SPECTRAL_SHAPE_CIE2017.start,
SPECTRAL_SHAPE_CIE2017.end,
sd_test.shape.interval,
)
CCT, D_uv = tsplit(CCT_reference_illuminant(sd_test))
sd_reference = sd_reference_illuminant(CCT, shape)
# NOTE: All computations except CCT calculation use the
# "CIE 1964 10 Degree Standard Observer".
# pylint: disable=E1102
cmfs_10 = reshape_msds(
MSDS_CMFS["CIE 1964 10 Degree Standard Observer"], shape
)
# pylint: disable=E1102
sds_tcs = reshape_msds(load_TCS_CIE2017(shape), shape)
test_tcs_colorimetry_data = tcs_colorimetry_data(sd_test, sds_tcs, cmfs_10)
reference_tcs_colorimetry_data = tcs_colorimetry_data(
sd_reference, sds_tcs, cmfs_10
)
delta_E_s = np.empty(len(sds_tcs.labels))
for i, _delta_E in enumerate(delta_E_s):
delta_E_s[i] = euclidean_distance(
test_tcs_colorimetry_data[i].Jpapbp,
reference_tcs_colorimetry_data[i].Jpapbp,
)
R_s = delta_E_to_R_f(delta_E_s)
R_f = cast(float, delta_E_to_R_f(np.average(delta_E_s)))
if additional_data:
return ColourRendering_Specification_CIE2017(
sd_test.name,
sd_reference,
R_f,
R_s,
CCT,
D_uv,
(test_tcs_colorimetry_data, reference_tcs_colorimetry_data),
delta_E_s,
)
else:
return R_f
def load_TCS_CIE2017(shape: SpectralShape) -> MultiSpectralDistributions:
"""
Load the *CIE 2017 Test Colour Samples* dataset appropriate for the given
spectral shape.
The datasets are cached and won't 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 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 CCT_reference_illuminant(sd: SpectralDistribution) -> NDArrayFloat:
"""
Compute the reference illuminant correlated colour temperature
:math:`T_{cp}` and :math:`\\Delta_{uv}` for given test spectral
distribution using *Ohno (2013)* method.
Parameters
----------
sd
Test spectral distribution.
Returns
-------
:class:`numpy.ndarray`
Correlated colour temperature :math:`T_{cp}`, :math:`\\Delta_{uv}`.
Examples
--------
>>> from colour import SDS_ILLUMINANTS
>>> sd = SDS_ILLUMINANTS["FL2"]
>>> CCT_reference_illuminant(sd) # doctest: +ELLIPSIS
array([ 4.2244697...e+03, 1.7871111...e-03])
"""
XYZ = sd_to_XYZ(sd)
return uv_to_CCT_Ohno2013(UCS_to_uv(XYZ_to_UCS(XYZ)))
def sd_reference_illuminant(
CCT: float, shape: SpectralShape
) -> SpectralDistribution:
"""
Compute the reference illuminant for a given correlated colour temperature
:math:`T_{cp}` for use in *CIE 2017 Colour Fidelity Index* (CFI)
computation.
Parameters
----------
CCT
Correlated colour temperature :math:`T_{cp}`.
shape
Desired shape of the returned spectral distribution.
Returns
-------
:class:`colour.SpectralDistribution`
Reference illuminant for *CIE 2017 Colour Fidelity Index* (CFI)
computation.
Examples
--------
>>> from colour.utilities import numpy_print_options
>>> with numpy_print_options(suppress=True):
... sd_reference_illuminant( # doctest: +ELLIPSIS
... 4224.469705295263300, SpectralShape(380, 780, 20)
... )
...
SpectralDistribution([[ 380. , 0.0034089...],
[ 400. , 0.0044208...],
[ 420. , 0.0053260...],
[ 440. , 0.0062857...],
[ 460. , 0.0072767...],
[ 480. , 0.0080207...],
[ 500. , 0.0086590...],
[ 520. , 0.0092242...],
[ 540. , 0.0097686...],
[ 560. , 0.0101444...],
[ 580. , 0.0104475...],
[ 600. , 0.0107642...],
[ 620. , 0.0110439...],
[ 640. , 0.0112535...],
[ 660. , 0.0113922...],
[ 680. , 0.0115185...],
[ 700. , 0.0113155...],
[ 720. , 0.0108192...],
[ 740. , 0.0111582...],
[ 760. , 0.0101299...],
[ 780. , 0.0105638...]],
SpragueInterpolator,
{},
Extrapolator,
{'method': 'Constant', 'left': None, 'right': None})
"""
if CCT <= 5000:
sd_planckian = sd_blackbody(CCT, shape)
if CCT >= 4000:
xy = CCT_to_xy_CIE_D(CCT)
sd_daylight = sd_CIE_illuminant_D_series(xy).align(shape)
if CCT < 4000:
sd_reference = sd_planckian
elif 4000 <= CCT <= 5000:
# Planckian and daylight illuminant must be normalised so that the
# mixture isn't biased.
sd_planckian /= sd_to_XYZ(sd_planckian)[1]
sd_daylight /= sd_to_XYZ(sd_daylight)[1]
# Mixture: 4200K should be 80% Planckian, 20% CIE Illuminant D Series.
m = (CCT - 4000) / 1000
values = linstep_function(m, sd_planckian.values, sd_daylight.values)
name = (
f"{as_int_scalar(CCT)}K "
f"Blackbody & CIE Illuminant D Series Mixture - "
f"{as_float_scalar(100 * m):.1f}%"
)
sd_reference = SpectralDistribution(
values, shape.wavelengths, name=name
)
elif CCT > 5000:
sd_reference = sd_daylight
return sd_reference
def tcs_colorimetry_data(
sd_irradiance: SpectralDistribution,
sds_tcs: MultiSpectralDistributions,
cmfs: MultiSpectralDistributions,
) -> Tuple[DataColorimetry_TCS_CIE2017, ...]:
"""
Return the *test colour samples* colorimetry data under given 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 given test light
source or reference illuminant spectral distribution.
Examples
--------
>>> delta_E_to_R_f(4.4410383190) # doctest: +ELLIPSIS
70.1208254...
"""
XYZ_w = sd_to_XYZ(sd_ones(), cmfs, sd_irradiance)
Y_b = 20
L_A = 100
surround = VIEWING_CONDITIONS_CIECAM02["Average"]
tcs_data = []
for sd_tcs in sds_tcs.to_sds():
XYZ = sd_to_XYZ(sd_tcs, cmfs, sd_irradiance)
specification = XYZ_to_CIECAM02(XYZ, XYZ_w, L_A, Y_b, surround, True)
JMh = tstack(
[
cast(NDArrayFloat, specification.J),
cast(NDArrayFloat, specification.M),
cast(NDArrayFloat, specification.h),
]
)
Jpapbp = JMh_CIECAM02_to_CAM02UCS(JMh)
tcs_data.append(
DataColorimetry_TCS_CIE2017(
sd_tcs.name, XYZ, specification, JMh, Jpapbp
)
)
return tuple(tcs_data)
def delta_E_to_R_f(delta_E: ArrayLike) -> NDArrayFloat:
"""
Convert from 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)
c_f = 6.73
return as_float(10 * np.log1p(np.exp((100 - c_f * delta_E) / 10)))