"""
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
from dataclasses import dataclass
import numpy as np
from colour.algebra import Extrapolator, euclidean_distance, linstep_function
from colour.appearance import (
VIEWING_CONDITIONS_CIECAM02,
CAM_Specification_CIECAM02,
XYZ_to_CIECAM02,
)
from colour.colorimetry import (
MSDS_CMFS,
MultiSpectralDistributions,
SpectralDistribution,
SpectralShape,
msds_to_XYZ,
reshape_msds,
sd_blackbody,
sd_CIE_illuminant_D_series,
sd_to_XYZ,
)
from colour.hints import ArrayLike, List, NDArrayFloat, Tuple, 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,
as_float,
as_float_array,
as_float_scalar,
as_int_scalar,
attest,
is_caching_enabled,
tsplit,
tstack,
usage_warning,
)
__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",
"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 | 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
[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.1208244...
"""
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,
)
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)
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".
cmfs_10 = reshape_msds(
MSDS_CMFS["CIE 1964 10 Degree Standard Observer"], shape, copy=False
)
sds_tcs = load_TCS_CIE2017(shape)
(
test_tcs_colorimetry_data,
reference_tcs_colorimetry_data,
) = tcs_colorimetry_data([sd_test, sd_reference], sds_tcs, cmfs_10)
delta_E_s = euclidean_distance(
test_tcs_colorimetry_data.Jpapbp,
reference_tcs_colorimetry_data.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 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 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.2244776...e+03, 1.7885608...e-03])
"""
XYZ = sd_to_XYZ(sd.values, shape=sd.shape, method="Integration")
# NOTE: Use "CFI2017" and "TM30" recommended temperature range of 1,000K to
# 25,000K for performance.
return uv_to_CCT_Ohno2013(UCS_to_uv(XYZ_to_UCS(XYZ)), start=1000, end=25000)
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, shape=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.values, shape=shape, method="Integration"
)[1]
sd_daylight /= sd_to_XYZ(sd_daylight.values, shape=shape, method="Integration")[
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 | List[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
--------
>>> 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]
XYZ_w = np.full((len(sd_irradiance), 3), np.nan)
for idx, sd in enumerate(sd_irradiance):
XYZ_t = sd_to_XYZ(
sd.values,
cmfs,
shape=sd.shape,
method="Integration",
)
k = 100 / XYZ_t[1]
XYZ_w[idx] = k * XYZ_t
sd_irradiance[idx] = sd_irradiance[idx].copy() * k
XYZ_w = as_float_array(XYZ_w)
Y_b = 20
L_A = 100
surround = VIEWING_CONDITIONS_CIECAM02["Average"]
sds_tcs_t = np.tile(np.transpose(sds_tcs.values), (len(sd_irradiance), 1, 1))
sds_tcs_t = sds_tcs_t * np.reshape(
as_float_array([sd.values for sd in sd_irradiance]),
(len(sd_irradiance), 1, len(sd_irradiance[0])),
)
XYZ = msds_to_XYZ(
sds_tcs_t,
cmfs,
method="Integration",
shape=sds_tcs.shape,
)
specification = XYZ_to_CIECAM02(
XYZ,
np.reshape(XYZ_w, (len(sd_irradiance), 1, 3)),
L_A,
Y_b,
surround,
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)
tcs_data = []
specification = as_float_array(specification).transpose((0, 2, 1))
specification = [CAM_Specification_CIECAM02(*t) for t in specification]
for sd_idx in range(len(sd_irradiance)):
tcs_data.append(
DataColorimetry_TCS_CIE2017(
sds_tcs.display_labels,
XYZ[sd_idx],
specification[sd_idx],
JMh[sd_idx],
Jpapbp[sd_idx],
)
)
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)))