"""
sUCS Colourspace
================
Define the *sUCS* colourspace transformations.
- :func:`colour.XYZ_to_sUCS`
- :func:`colour.sUCS_to_XYZ`
- :func:`colour.sUCS_chroma`
- :func:`colour.sUCS_hue_angle`
The *sUCS* (Simple Uniform Colour Space) is designed for simplicity and
perceptual uniformity. This implementation is based on the work by
*Li & Luo (2024)*.
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 functools import partial
import numpy as np
from colour.algebra import spow
from colour.hints import ( # noqa: TC001
Domain1,
Domain100,
Domain100_100_360,
NDArrayFloat,
Range1,
Range100,
Range100_100_360,
Range360,
)
from colour.models import Iab_to_XYZ, XYZ_to_Iab
from colour.utilities import (
as_float,
domain_range_scale,
from_range_1,
from_range_100,
from_range_degrees,
to_domain_1,
to_domain_100,
to_domain_degrees,
tsplit,
tstack,
)
__author__ = "UltraMo114(Molin Li), 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__ = [
"MATRIX_SUCS_XYZ_TO_LMS",
"MATRIX_SUCS_LMS_TO_XYZ",
"MATRIX_SUCS_LMS_P_TO_IAB",
"MATRIX_SUCS_IAB_TO_LMS_P",
"XYZ_to_sUCS",
"sUCS_to_XYZ",
"sUCS_chroma",
"sUCS_hue_angle",
"sUCS_Iab_to_sUCS_ICh",
"sUCS_ICh_to_sUCS_Iab",
]
MATRIX_SUCS_XYZ_TO_LMS: NDArrayFloat = np.array(
[
[0.4002, 0.7075, -0.0807],
[-0.2280, 1.1500, 0.0612],
[0.0000, 0.0000, 0.9184],
]
)
"""
*CIE XYZ* tristimulus values (*CIE Standard Illuminant D Series* *D65*-adapted,
Y=1 for white) to LMS-like cone responses matrix.
"""
MATRIX_SUCS_LMS_TO_XYZ: NDArrayFloat = np.linalg.inv(MATRIX_SUCS_XYZ_TO_LMS)
"""
LMS-like cone responses to *CIE XYZ* tristimulus values
(*CIE Standard Illuminant D Series* *D65*-adapted, Y=1 for white) matrix.
"""
MATRIX_SUCS_LMS_P_TO_IAB: NDArrayFloat = np.array(
[
[200.0 / 3.05, 100.0 / 3.05, 5.0 / 3.05],
[430.0, -470.0, 40.0],
[49.0, 49.0, -98.0],
]
)
"""
Non-linear LMS-like responses :math:`LMS_p` to intermediate :math:`Iab`
colourspace matrix.
"""
MATRIX_SUCS_IAB_TO_LMS_P: NDArrayFloat = np.linalg.inv(MATRIX_SUCS_LMS_P_TO_IAB)
"""
Intermediate :math:`Iab` colourspace to non-linear LMS-like responses
:math:`LMS_p` matrix.
"""
[docs]
def XYZ_to_sUCS(XYZ: Domain1) -> Range100:
"""
Convert from *CIE XYZ* tristimulus values to *sUCS* colourspace.
Parameters
----------
XYZ
*CIE XYZ* tristimulus values, adapted to
*CIE Standard Illuminant D65* and in domain [0, 1] (where white
:math:`Y` is 1.0).
Returns
-------
:class:`numpy.ndarray`
*sUCS* :math:`Iab` colourspace array.
Notes
-----
+------------+-----------------------+-----------------+
| **Domain** | **Scale - Reference** | **Scale - 1** |
+============+=======================+=================+
| ``XYZ`` | 1 | 1 |
+------------+-----------------------+-----------------+
+------------+-----------------------+------------------+
| **Range** | **Scale - Reference** | **Scale - 1** |
+============+=======================+==================+
| ``Iab`` | 100 | 1 |
+------------+-----------------------+------------------+
- Input *CIE XYZ* tristimulus values must be adapted to
*CIE Standard Illuminant D Series* *D65*.
References
----------
:cite:`Li2024`
Examples
--------
>>> XYZ = np.array([0.20654008, 0.12197225, 0.05136952])
>>> XYZ_to_sUCS(XYZ) # doctest: +ELLIPSIS
array([ 42.6292365..., 36.9764683..., 14.1230135...])
"""
XYZ = to_domain_1(XYZ)
with domain_range_scale("ignore"):
Iab = XYZ_to_Iab(
XYZ,
partial(spow, p=0.43),
MATRIX_SUCS_XYZ_TO_LMS,
MATRIX_SUCS_LMS_P_TO_IAB,
)
return from_range_100(Iab)
[docs]
def sUCS_to_XYZ(Iab: Domain100) -> Range1:
"""
Convert from *sUCS* colourspace to *CIE XYZ* tristimulus values.
Parameters
----------
Iab
*sUCS* :math:`Iab` colourspace array.
Returns
-------
:class:`numpy.ndarray`
*CIE XYZ* tristimulus values, adapted to
*CIE Standard Illuminant D65* and in domain [0, 1] (where white
:math:`Y` is 1.0).
Notes
-----
+------------+-----------------------+------------------+
| **Domain** | **Scale - Reference** | **Scale - 1** |
+============+=======================+==================+
| ``Iab`` | 100 | 1 |
+------------+-----------------------+------------------+
+------------+-----------------------+-----------------+
| **Range** | **Scale - Reference** | **Scale - 1** |
+============+=======================+=================+
| ``XYZ`` | 1 | 1 |
+------------+-----------------------+-----------------+
References
----------
:cite:`Li2024`
Examples
--------
>>> Iab = np.array([42.62923653, 36.97646831, 14.12301358])
>>> sUCS_to_XYZ(Iab) # doctest: +ELLIPSIS
array([ 0.2065400..., 0.1219722..., 0.0513695...])
"""
Iab = to_domain_100(Iab)
with domain_range_scale("ignore"):
XYZ = Iab_to_XYZ(
Iab,
partial(spow, p=1 / 0.43),
MATRIX_SUCS_IAB_TO_LMS_P,
MATRIX_SUCS_LMS_TO_XYZ,
)
return from_range_1(XYZ)
[docs]
def sUCS_chroma(Iab: Domain100) -> Range100:
"""
Compute the chroma component from the *sUCS* colourspace.
Parameters
----------
Iab
*sUCS* :math:`Iab` colourspace array.
Returns
-------
:class:`numpy.ndarray`
Chroma component.
Notes
-----
+------------+-----------------------+------------------+
| **Domain** | **Scale - Reference** | **Scale - 1** |
+============+=======================+==================+
| ``Iab`` | 100 | 1 |
+------------+-----------------------+------------------+
+------------+-----------------------+-----------------+
| **Range** | **Scale - Reference** | **Scale - 1** |
+============+=======================+=================+
| ``C`` | 100 | 1 |
+------------+-----------------------+-----------------+
References
----------
:cite:`Li2024`
Examples
--------
>>> Iab = np.array([42.62923653, 36.97646831, 14.12301358])
>>> sUCS_chroma(Iab) # doctest: +ELLIPSIS
40.4205110...
"""
_I, a, b = tsplit(to_domain_100(Iab))
C = 1 / 0.0252 * np.log(1 + 0.0447 * np.hypot(a, b))
return as_float(from_range_100(C))
[docs]
def sUCS_hue_angle(Iab: Domain100) -> Range360:
"""
Compute the hue angle in degrees from the *sUCS* colourspace.
Parameters
----------
Iab
*sUCS* :math:`Iab` colourspace array.
Returns
-------
:class:`numpy.ndarray`
Hue angle in degrees.
Notes
-----
+------------+-----------------------+------------------+
| **Domain** | **Scale - Reference** | **Scale - 1** |
+============+=======================+==================+
| ``Iab`` | 100 | 1 |
+------------+-----------------------+------------------+
+------------+-----------------------+-----------------+
| **Range** | **Scale - Reference** | **Scale - 1** |
+============+=======================+=================+
| ``hue`` | 360 | 1 |
+------------+-----------------------+-----------------+
References
----------
:cite:`Li2024`
Examples
--------
>>> Iab = np.array([42.62923653, 36.97646831, 14.12301358])
>>> sUCS_hue_angle(Iab) # doctest: +ELLIPSIS
20.9041560...
"""
_I, a, b = tsplit(to_domain_100(Iab))
h = np.degrees(np.arctan2(b, a)) % 360
return as_float(from_range_degrees(h))
[docs]
def sUCS_Iab_to_sUCS_ICh(
Iab: Domain100,
) -> Range100_100_360:
"""
Convert from *sUCS* :math:`Iab` rectangular coordinates to *sUCS*
:math:`ICh` cylindrical coordinates.
Parameters
----------
Iab
*sUCS* :math:`Iab` rectangular coordinates array.
Returns
-------
:class:`numpy.ndarray`
*sUCS* :math:`ICh` cylindrical coordinates array.
Notes
-----
+------------+-----------------------+------------------+
| **Domain** | **Scale - Reference** | **Scale - 1** |
+============+=======================+==================+
| ``Iab`` | 100 | 1 |
+------------+-----------------------+------------------+
+------------+-----------------------+------------------+
| **Range** | **Scale - Reference** | **Scale - 1** |
+============+=======================+==================+
| ``ICh`` | ``I`` : 100 | ``I`` : 1 |
| | | |
| | ``C`` : 100 | ``C`` : 1 |
| | | |
| | ``h`` : 360 | ``h`` : 1 |
+------------+-----------------------+------------------+
References
----------
:cite:`Li2024`
Examples
--------
>>> Iab = np.array([42.62923653, 36.97646831, 14.12301358])
>>> sUCS_Iab_to_sUCS_ICh(Iab) # doctest: +ELLIPSIS
array([ 42.6292365..., 40.4205110..., 20.9041560...])
"""
I, a, b = tsplit(to_domain_100(Iab)) # noqa: E741
C = 1 / 0.0252 * np.log(1 + 0.0447 * np.hypot(a, b))
h = np.degrees(np.arctan2(b, a)) % 360
return tstack([from_range_100(I), from_range_100(C), from_range_degrees(h)])
[docs]
def sUCS_ICh_to_sUCS_Iab(
ICh: Domain100_100_360,
) -> Range100:
"""
Convert from *sUCS* :math:`ICh` cylindrical coordinates to *sUCS*
:math:`Iab` rectangular coordinates.
Parameters
----------
ICh
*sUCS* :math:`ICh` cylindrical coordinates array.
Returns
-------
:class:`numpy.ndarray`
*sUCS* :math:`Iab` rectangular coordinates array.
Notes
-----
+------------+-----------------------+------------------+
| **Domain** | **Scale - Reference** | **Scale - 1** |
+============+=======================+==================+
| ``ICh`` | ``I`` : 100 | ``I`` : 1 |
| | | |
| | ``C`` : 100 | ``C`` : 1 |
| | | |
| | ``h`` : 360 | ``h`` : 1 |
+------------+-----------------------+------------------+
+------------+-----------------------+------------------+
| **Range** | **Scale - Reference** | **Scale - 1** |
+============+=======================+==================+
| ``Iab`` | 100 | 1 |
+------------+-----------------------+------------------+
References
----------
:cite:`Li2024`
Examples
--------
>>> ICh = np.array([42.62923653, 40.42051103, 20.90415604])
>>> sUCS_ICh_to_sUCS_Iab(ICh) # doctest: +ELLIPSIS
array([ 42.6292365..., 36.9764682..., 14.1230135...])
"""
I, C, h = tsplit(ICh) # noqa: E741
I = to_domain_100(I) # noqa: E741
C = to_domain_100(C)
h = to_domain_degrees(h)
C = (np.exp(0.0252 * C) - 1) / 0.0447
a = C * np.cos(np.radians(h))
b = C * np.sin(np.radians(h))
return from_range_100(tstack([I, a, b]))