"""
ATD (1995) Colour Vision Model
==============================
Define the *ATD (1995)* colour vision model.
- :class:`colour.CAM_Specification_ATD95`
- :func:`colour.XYZ_to_ATD95`
Notes
-----
- According to *CIE TC1-34* definition of a colour appearance model, the
*ATD (1995)* model cannot be considered as a colour appearance model.
It was developed with different aims and is described as a model of
colour vision.
References
----------
- :cite:`Fairchild2013v` : Fairchild, M. D. (2013). ATD Model. In Color
Appearance Models (3rd ed., pp. 5852-5991). Wiley. ISBN:B00DAYO8E2
- :cite:`Guth1995a` : Guth, S. L. (1995). Further applications of the ATD
model for color vision. In E. Walowit (Ed.), Proc. SPIE 2414,
Device-Independent Color Imaging II (Vol. 2414, pp. 12-26).
doi:10.1117/12.206546
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
from colour.algebra import spow, vecmul
from colour.hints import Annotated, ArrayLike, Domain100, NDArrayFloat # noqa: TC001
from colour.utilities import (
MixinDataclassArithmetic,
as_float,
as_float_array,
from_range_degrees,
to_domain_100,
tsplit,
tstack,
)
__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__ = [
"CAM_ReferenceSpecification_ATD95",
"CAM_Specification_ATD95",
"XYZ_to_ATD95",
"luminance_to_retinal_illuminance",
"XYZ_to_LMS_ATD95",
"opponent_colour_dimensions",
"final_response",
]
@dataclass
class CAM_ReferenceSpecification_ATD95(MixinDataclassArithmetic):
"""
Define the *ATD (1995)* colour vision model reference specification.
This specification contains field names consistent with the *Fairchild
(2013)* reference.
Parameters
----------
H
*Hue* angle :math:`H` in degrees.
C
Correlate of *saturation* :math:`C`. *Guth (1995)* incorrectly uses
the terms saturation and chroma interchangeably. However, :math:`C`
represents a measure of saturation rather than chroma since it is
calculated relative to the achromatic response for the stimulus
rather than that of a similarly illuminated white.
Br
Correlate of *brightness* :math:`Br`.
A_1
First stage :math:`A_1` response.
T_1
First stage :math:`T_1` response.
D_1
First stage :math:`D_1` response.
A_2
Second stage :math:`A_2` response.
T_2
Second stage :math:`A_2` response.
D_2
Second stage :math:`D_2` response.
References
----------
:cite:`Fairchild2013v`, :cite:`Guth1995a`
"""
H: float | NDArrayFloat | None = field(default_factory=lambda: None)
C: float | NDArrayFloat | None = field(default_factory=lambda: None)
Br: float | NDArrayFloat | None = field(default_factory=lambda: None)
A_1: float | NDArrayFloat | None = field(default_factory=lambda: None)
T_1: float | NDArrayFloat | None = field(default_factory=lambda: None)
D_1: float | NDArrayFloat | None = field(default_factory=lambda: None)
A_2: float | NDArrayFloat | None = field(default_factory=lambda: None)
T_2: float | NDArrayFloat | None = field(default_factory=lambda: None)
D_2: float | NDArrayFloat | None = field(default_factory=lambda: None)
[docs]
@dataclass
class CAM_Specification_ATD95(MixinDataclassArithmetic):
"""
Define the *ATD (1995)* colour vision model specification.
This specification provides a standardized interface for the *ATD (1995)*
model with field names consistent across all colour appearance models in
:mod:`colour.appearance`. While the field names differ from the original
*Fairchild (2013)* reference notation, they map directly to the model's
perceptual correlates.
Parameters
----------
h
*Hue* angle :math:`H` in degrees.
C
Correlate of *saturation* :math:`C`. *Guth (1995)* incorrectly uses
the terms saturation and chroma interchangeably. However, :math:`C`
represents a measure of saturation rather than chroma since it is
measured relative to the achromatic response for the stimulus rather
than that of a similarly illuminated white.
Q
Correlate of *brightness* :math:`Br`.
A_1
First stage :math:`A_1` response.
T_1
First stage :math:`T_1` response.
D_1
First stage :math:`D_1` response.
A_2
Second stage :math:`A_2` response.
T_2
Second stage :math:`T_2` response.
D_2
Second stage :math:`D_2` response.
Notes
-----
- This specification is the one used in the current model
implementation.
References
----------
:cite:`Fairchild2013v`, :cite:`Guth1995a`
"""
h: float | NDArrayFloat | None = field(default_factory=lambda: None)
C: float | NDArrayFloat | None = field(default_factory=lambda: None)
Q: float | NDArrayFloat | None = field(default_factory=lambda: None)
A_1: float | NDArrayFloat | None = field(default_factory=lambda: None)
T_1: float | NDArrayFloat | None = field(default_factory=lambda: None)
D_1: float | NDArrayFloat | None = field(default_factory=lambda: None)
A_2: float | NDArrayFloat | None = field(default_factory=lambda: None)
T_2: float | NDArrayFloat | None = field(default_factory=lambda: None)
D_2: float | NDArrayFloat | None = field(default_factory=lambda: None)
[docs]
def XYZ_to_ATD95(
XYZ: Domain100,
XYZ_0: Domain100,
Y_0: ArrayLike,
k_1: ArrayLike,
k_2: ArrayLike,
sigma: ArrayLike = 300,
) -> Annotated[CAM_Specification_ATD95, 360]:
"""
Compute the *ATD (1995)* colour vision model correlates from the specified
*CIE XYZ* tristimulus values.
Parameters
----------
XYZ
*CIE XYZ* tristimulus values of test sample / stimulus.
XYZ_0
*CIE XYZ* tristimulus values of reference white.
Y_0
Absolute adapting field luminance in :math:`cd/m^2`.
k_1
Application specific weight :math:`k_1`.
k_2
Application specific weight :math:`k_2`.
sigma
Constant :math:`\\sigma` varied to predict different types of data.
Returns
-------
:class:`colour.CAM_Specification_ATD95`
*ATD (1995)* colour vision model specification.
Notes
-----
+---------------------+-----------------------+---------------+
| **Domain** | **Scale - Reference** | **Scale - 1** |
+=====================+=======================+===============+
| ``XYZ`` | 100 | 1 |
+---------------------+-----------------------+---------------+
| ``XYZ_0`` | 100 | 1 |
+---------------------+-----------------------+---------------+
+---------------------+-----------------------+---------------+
| **Range** | **Scale - Reference** | **Scale - 1** |
+=====================+=======================+===============+
| ``specification.h`` | 360 | 1 |
+---------------------+-----------------------+---------------+
- For unrelated colours, there is only self-adaptation and :math:`k_1`
is set to 1.0 while :math:`k_2` is set to 0.0. For related colours
such as typical colorimetric applications, :math:`k_1` is set to 0.0
and :math:`k_2` is set to a value between 15 and 50 *(Guth, 1995)*.
References
----------
:cite:`Fairchild2013v`, :cite:`Guth1995a`
Examples
--------
>>> XYZ = np.array([19.01, 20.00, 21.78])
>>> XYZ_0 = np.array([95.05, 100.00, 108.88])
>>> Y_0 = 318.31
>>> k_1 = 0.0
>>> k_2 = 50.0
>>> XYZ_to_ATD95(XYZ, XYZ_0, Y_0, k_1, k_2) # doctest: +ELLIPSIS
CAM_Specification_ATD95(h=np.float64(1.9089869...), \
C=np.float64(1.2064060...), Q=np.float64(0.1814003...), \
A_1=np.float64(0.1787931...), T_1=np.float64(0.0286942...), \
D_1=np.float64(0.0107584...), A_2=np.float64(0.0192182...), \
T_2=np.float64(0.0205377...), D_2=np.float64(0.0107584...))
"""
XYZ = to_domain_100(XYZ)
XYZ_0 = to_domain_100(XYZ_0)
Y_0 = as_float_array(Y_0)
k_1 = as_float_array(k_1)
k_2 = as_float_array(k_2)
sigma = as_float_array(sigma)
XYZ = luminance_to_retinal_illuminance(XYZ, Y_0)
XYZ_0 = luminance_to_retinal_illuminance(XYZ_0, Y_0)
# Computing adaptation model.
LMS = XYZ_to_LMS_ATD95(XYZ)
XYZ_a = k_1[..., None] * XYZ + k_2[..., None] * XYZ_0
LMS_a = XYZ_to_LMS_ATD95(XYZ_a)
LMS_g = LMS * (sigma[..., None] / (sigma[..., None] + LMS_a))
# Computing opponent colour dimensions.
A_1, T_1, D_1, A_2, T_2, D_2 = tsplit(opponent_colour_dimensions(LMS_g))
# Computing the correlate of *brightness* :math:`Br`.
Br = spow(A_1**2 + T_1**2 + D_1**2, 0.5)
# Computing the correlate of *saturation* :math:`C`.
C = spow(T_2**2 + D_2**2, 0.5) / A_2
# Computing the *hue* :math:`H`. Note that the reference does not take the
# modulus of the :math:`H`, thus :math:`H` can exceed 360 degrees.
H = T_2 / D_2
return CAM_Specification_ATD95(
h=as_float(from_range_degrees(H)),
C=C,
Q=Br,
A_1=A_1,
T_1=T_1,
D_1=D_1,
A_2=A_2,
T_2=T_2,
D_2=D_2,
)
def luminance_to_retinal_illuminance(XYZ: ArrayLike, Y_c: ArrayLike) -> NDArrayFloat:
"""
Convert luminance in :math:`cd/m^2` to retinal illuminance in trolands.
This function converts photometric luminance values to retinal illuminance
by applying a power transformation that accounts for pupil area effects
under the specified adapting field luminance conditions.
Parameters
----------
XYZ
*CIE XYZ* tristimulus values in photometric units.
Y_c
Absolute adapting field luminance in :math:`cd/m^2`.
Returns
-------
:class:`numpy.ndarray`
Retinal illuminance values in trolands corresponding to the
tristimulus values.
Examples
--------
>>> XYZ = np.array([19.01, 20.00, 21.78])
>>> Y_0 = 318.31
>>> luminance_to_retinal_illuminance(XYZ, Y_0) # doctest: +ELLIPSIS
array([479.4445924..., 499.3174313..., 534.5631673...])
"""
XYZ = as_float_array(XYZ)
Y_c = as_float_array(Y_c)
return 18 * spow(Y_c[..., None] * XYZ / 100, 0.8)
def XYZ_to_LMS_ATD95(XYZ: ArrayLike) -> NDArrayFloat:
"""
Convert *CIE XYZ* tristimulus values to *LMS* cone responses using the
*ATD95* colour appearance model.
Parameters
----------
XYZ
*CIE XYZ* tristimulus values.
Returns
-------
:class:`numpy.ndarray`
*LMS* cone responses.
Examples
--------
>>> XYZ = np.array([19.01, 20.00, 21.78])
>>> XYZ_to_LMS_ATD95(XYZ) # doctest: +ELLIPSIS
array([6.2283272..., 7.4780666..., 3.8859772...])
"""
LMS = vecmul(
[
[0.2435, 0.8524, -0.0516],
[-0.3954, 1.1642, 0.0837],
[0.0000, 0.0400, 0.6225],
],
XYZ,
)
LMS = LMS * np.array([0.66, 1.0, 0.43])
LMS_p = spow(LMS, 0.7)
return LMS_p + np.array([0.024, 0.036, 0.31])
def opponent_colour_dimensions(LMS_g: ArrayLike) -> NDArrayFloat:
"""
Compute opponent colour dimensions from the specified post-adaptation cone
signals.
Parameters
----------
LMS_g
Post-adaptation cone signals.
Returns
-------
:class:`numpy.ndarray`
Opponent colour dimensions.
Examples
--------
>>> LMS_g = np.array([6.95457922, 7.08945043, 6.44069316])
>>> opponent_colour_dimensions(LMS_g) # doctest: +ELLIPSIS
array([0.1787931..., 0.0286942..., 0.0107584..., 0.0192182..., ...])
"""
L_g, M_g, S_g = tsplit(LMS_g)
A_1i = 3.57 * L_g + 2.64 * M_g
T_1i = 7.18 * L_g - 6.21 * M_g
D_1i = -0.7 * L_g + 0.085 * M_g + S_g
A_2i = 0.09 * A_1i
T_2i = 0.43 * T_1i + 0.76 * D_1i
D_2i = D_1i
A_1 = final_response(A_1i)
T_1 = final_response(T_1i)
D_1 = final_response(D_1i)
A_2 = final_response(A_2i)
T_2 = final_response(T_2i)
D_2 = final_response(D_2i)
return tstack([A_1, T_1, D_1, A_2, T_2, D_2])
def final_response(value: ArrayLike) -> NDArrayFloat:
"""
Compute the final response of the specified opponent colour dimension.
Parameters
----------
value
Opponent colour dimension.
Returns
-------
:class:`numpy.ndarray`
Final response of the opponent colour dimension.
Examples
--------
>>> final_response(43.54399695501678) # doctest: +ELLIPSIS
np.float64(0.1787931...)
"""
value = as_float_array(value)
return as_float(value / (200 + np.abs(value)))