diff --git a/colour/models/rgb/rgb_colourspace.py b/colour/models/rgb/rgb_colourspace.py index 56756ed29f..1137196c7e 100644 --- a/colour/models/rgb/rgb_colourspace.py +++ b/colour/models/rgb/rgb_colourspace.py @@ -26,6 +26,7 @@ from __future__ import annotations from copy import deepcopy +from functools import partial import numpy as np @@ -277,6 +278,63 @@ def __init__( self._use_derived_matrix_XYZ_to_RGB: bool = False self.use_derived_matrix_XYZ_to_RGB = use_derived_matrix_XYZ_to_RGB + def __eq__(self, other: object) -> bool: + """Return weather or not two RGB spaces are equivalent and would produce + the same results with XYZ_to_RGB and visa-versa. Can detect and compare + instances of `partial` for the cctf properties. + + Parameters + ---------- + other : object + + Returns + ------- + bool + """ + if not isinstance(other, RGB_Colourspace): + return False + + cctf_decoding_eq: bool + if isinstance(self.cctf_decoding, partial) and isinstance( + other.cctf_decoding, partial + ): + cctf_decoding_eq = np.all( + ( + self.cctf_decoding.func == other.cctf_decoding.func, + np.all(self.cctf_decoding.args == other.cctf_decoding.args), + np.all(self.cctf_decoding.keywords == other.cctf_decoding.keywords), + ) + ) + else: + cctf_decoding_eq = self.cctf_decoding == other.cctf_decoding + + cctf_encoding_eq: bool + if isinstance(self.cctf_encoding, partial) and isinstance( + other.cctf_encoding, partial + ): + cctf_encoding_eq = np.all( + ( + self.cctf_encoding.func == other.cctf_encoding.func, + np.all(self.cctf_encoding.args == other.cctf_encoding.args), + np.all(self.cctf_encoding.keywords == other.cctf_encoding.keywords), + ) + ) + else: + cctf_encoding_eq = self.cctf_encoding == other.cctf_encoding + + return np.all( + ( + self.name == other.name, + np.all(self.primaries == other.primaries), + np.all(self.whitepoint == self.whitepoint), + self.whitepoint_name == self.whitepoint_name, + np.all(self.matrix_RGB_to_XYZ == other.matrix_RGB_to_XYZ), + np.all(self.matrix_XYZ_to_RGB == other.matrix_XYZ_to_RGB), + cctf_decoding_eq, + cctf_encoding_eq, + ) + ) + @property def name(self) -> str: """ diff --git a/colour/models/rgb/tests/test_rgb_colourspace.py b/colour/models/rgb/tests/test_rgb_colourspace.py index d0cfc1d096..c689b38594 100644 --- a/colour/models/rgb/tests/test_rgb_colourspace.py +++ b/colour/models/rgb/tests/test_rgb_colourspace.py @@ -4,6 +4,7 @@ import re import textwrap +from functools import partial from itertools import product import numpy as np @@ -24,6 +25,7 @@ matrix_RGB_to_RGB, normalised_primary_matrix, ) +from colour.models.rgb.transfer_functions.gamma import gamma_function from colour.utilities import domain_range_scale, ignore_numpy_errors __author__ = "Colour Developers" @@ -66,6 +68,77 @@ def setup_method(self): linear_function, ) + class TestRGBSpace__eq__: + def setup_method(self): + pass + # Some pytest possible issue requires this since the encapsulating + # class has a setup_method. + + @staticmethod + def get_two_rgb_spaces(): + p = np.array([0.73470, 0.26530, 0.00000, 1.00000, 0.00010, -0.07700]) + whitepoint = np.array([0.32168, 0.33767]) + matrix_RGB_to_XYZ = np.identity(3) + matrix_XYZ_to_RGB = np.identity(3) + + s1 = RGB_Colourspace( + "RGB Colourspace", + p, + whitepoint, + "ACES", + matrix_RGB_to_XYZ, + matrix_XYZ_to_RGB, + linear_function, + linear_function, + ) + + s2 = RGB_Colourspace( + "RGB Colourspace", + p, + whitepoint, + "ACES", + matrix_RGB_to_XYZ, + matrix_XYZ_to_RGB, + linear_function, + linear_function, + ) + + return s1, s2 + + def test_simple_eq(self): + s1, s2 = self.get_two_rgb_spaces() + + assert s1 is not s2 + assert s1 == s2 + + # Even if one space uses derived, if they return the same matrix + # they are equivalent + s2.use_derived_matrix_RGB_to_XYZ = True + s1.matrix_RGB_to_XYZ = s2.matrix_RGB_to_XYZ + s1.matrix_XYZ_to_RGB = s2.matrix_XYZ_to_RGB + + assert s1 == s2 + + def test_partial_cctf(self): + s1, s2 = self.get_two_rgb_spaces() + + s1.cctf_decoding = partial(gamma_function, exponent=2) + s1.cctf_encoding = partial(gamma_function, exponent=1 / 2) + assert s1 != s2 + + s2.cctf_encoding = partial(gamma_function, exponent=1 / 2) + assert s1 != s2 + + s2.cctf_decoding = partial(gamma_function, exponent=1.5) + assert s1 != s2 + + s2.cctf_decoding = partial(gamma_function, 2) + # Should be equal, but because one partial uses kwargs it is an error + assert s1 != s2 + + s2.cctf_decoding = partial(gamma_function, exponent=2) + assert s1 == s2 + def test_required_attributes(self): """Test the presence of required attributes.""" @@ -92,6 +165,7 @@ def test_required_methods(self): "__init__", "__str__", "__repr__", + "__eq__", "use_derived_transformation_matrices", "chromatically_adapt", "copy", diff --git a/colour/models/rgb/transfer_functions/gamma.py b/colour/models/rgb/transfer_functions/gamma.py index 49eb6d7fc9..087a9d4dcd 100644 --- a/colour/models/rgb/transfer_functions/gamma.py +++ b/colour/models/rgb/transfer_functions/gamma.py @@ -25,15 +25,107 @@ __all__ = [ "gamma_function", + "GammaFunction", ] +NegativeNumberHandlingType = ( + Literal["Clamp", "Indeterminate", "Mirror", "Preserve"] | str +) + + +class GammaFunction: + """Provides an object oriented interface to contain optional parameters for + an underlying :func:gamma_function call. Useful for providing both a simpler + and constructed api for gamma_function as well as allowing for control flow. + """ + + def __init__( + self, + exponent: float = 1, + negative_number_handling: NegativeNumberHandlingType = "Indeterminate", + ): + """ + Construct an object oriented interface to contain optional parameters for + an underlying :func:gamma_function call. Useful for providing both a simpler + and constructed api for gamma_function as well as allowing for control flow. + + Parameters + ---------- + exponent : float, optional + The exponent value in a^b, by default 1 + negative_number_handling : NegativeNumberHandlingType, optional + Defines the behavior for negative number handling, by default + "Indeterminate" + + See Also + -------- + :func:gamma_function + """ + self._exponent = exponent + self._negative_number_handling = negative_number_handling + + @property + def exponent(self) -> float: + """The exponent, b, in the function a^b + + Returns + ------- + float + """ + return self._exponent + + @property + def negative_number_handling(self) -> NegativeNumberHandlingType: + """How to treat negative numbers. See also :func:gamma_function + + Returns + ------- + NegativeNumberHandlingType + See also :func:gamma_function + """ + return self._negative_number_handling + + def __call__(self, a: ArrayLike): + """Calculate a typical encoding / decoding function on `a`. Representative + of the function a ^ b where b is determined by the instance value of + `exponent` and negative handling behavior is defined by the instance + value `negative_number_handling`. See also :func:gamma_function + + Parameters + ---------- + a : ArrayLike + """ + return gamma_function( + a, + exponent=self.exponent, + negative_number_handling=self.negative_number_handling, + ) + + def __eq__(self, other: object) -> bool: + """Return if two gamma functions have the same parameters and therefore + produce the same results + + Parameters + ---------- + other : object + + Returns + ------- + bool + """ + if not isinstance(other, GammaFunction): + return False + + return ( + self.exponent == other.exponent + and self.negative_number_handling == other.negative_number_handling + ) + def gamma_function( a: ArrayLike, exponent: ArrayLike = 1, - negative_number_handling: ( - Literal["Clamp", "Indeterminate", "Mirror", "Preserve"] | str - ) = "Indeterminate", + negative_number_handling: NegativeNumberHandlingType = "Indeterminate", ) -> NDArrayFloat: """ Define a typical gamma encoding / decoding function. diff --git a/colour/models/rgb/transfer_functions/tests/test_gamma.py b/colour/models/rgb/transfer_functions/tests/test_gamma.py index 05a7e1cb99..4d4a92ce9c 100644 --- a/colour/models/rgb/transfer_functions/tests/test_gamma.py +++ b/colour/models/rgb/transfer_functions/tests/test_gamma.py @@ -3,11 +3,11 @@ :mod:`colour.models.rgb.transfer_functions.gamma` module. """ - import numpy as np from colour.constants import TOLERANCE_ABSOLUTE_TESTS from colour.models.rgb.transfer_functions import gamma_function +from colour.models.rgb.transfer_functions.gamma import GammaFunction from colour.utilities import ignore_numpy_errors __author__ = "Colour Developers" @@ -22,6 +22,209 @@ ] +class TestGammaFunctionClass: + def test_gamma_function_class(self): + """ + Test :func:`colour.models.rgb.transfer_functions.gamma.\ + gamma_function` definition. + """ + + np.testing.assert_allclose( + GammaFunction(2.2)(0.0), 0.0, atol=TOLERANCE_ABSOLUTE_TESTS + ) + + np.testing.assert_allclose( + GammaFunction(2.2)(0.18), + 0.022993204992707, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + np.testing.assert_allclose( + GammaFunction(1.0 / 2.2)(0.022993204992707), + 0.18, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + np.testing.assert_allclose( + GammaFunction(2.0)(-0.18), + 0.0323999999999998, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + np.testing.assert_array_equal(GammaFunction(2.2)(-0.18), np.nan) + + np.testing.assert_allclose( + GammaFunction(2.2, "Mirror")(-0.18), + -0.022993204992707, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + np.testing.assert_allclose( + GammaFunction(2.2, "Preserve")(-0.18), + -0.18, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + np.testing.assert_allclose( + GammaFunction(2.2, "Clamp")(-0.18), + 0, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + np.testing.assert_array_equal(GammaFunction(-2.2)(-0.18), np.nan) + + np.testing.assert_allclose( + GammaFunction(-2.2, "Mirror")(0.0), + 0.0, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + np.testing.assert_allclose( + GammaFunction(2.2, "Preserve")(0.0), + 0.0, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + np.testing.assert_allclose( + GammaFunction(2.2, "Clamp")(0.0), 0, atol=TOLERANCE_ABSOLUTE_TESTS + ) + + def test_n_dimensional_gamma_function(self): + """ + Test :func:`colour.models.rgb.transfer_functions.gamma.\ +gamma_function` definition n-dimensional arrays support. + """ + + a = 0.18 + a_p = GammaFunction(2.2)(a) + + a = np.tile(a, 6) + a_p = np.tile(a_p, 6) + np.testing.assert_allclose( + GammaFunction(2.2)(a), a_p, atol=TOLERANCE_ABSOLUTE_TESTS + ) + + a = np.reshape(a, (2, 3)) + a_p = np.reshape(a_p, (2, 3)) + np.testing.assert_allclose( + GammaFunction(2.2)(a), a_p, atol=TOLERANCE_ABSOLUTE_TESTS + ) + + a = np.reshape(a, (2, 3, 1)) + a_p = np.reshape(a_p, (2, 3, 1)) + np.testing.assert_allclose( + GammaFunction(2.2)(a), a_p, atol=TOLERANCE_ABSOLUTE_TESTS + ) + + a = -0.18 + a_p = -0.022993204992707 + np.testing.assert_allclose( + GammaFunction(2.2, "Mirror")(a), + a_p, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + a = np.tile(a, 6) + a_p = np.tile(a_p, 6) + np.testing.assert_allclose( + GammaFunction(2.2, "Mirror")(a), + a_p, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + a = np.reshape(a, (2, 3)) + a_p = np.reshape(a_p, (2, 3)) + np.testing.assert_allclose( + GammaFunction(2.2, "Mirror")(a), + a_p, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + a = np.reshape(a, (2, 3, 1)) + a_p = np.reshape(a_p, (2, 3, 1)) + np.testing.assert_allclose( + GammaFunction(2.2, "Mirror")(a), + a_p, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + a = -0.18 + a_p = -0.18 + np.testing.assert_allclose( + GammaFunction(2.2, "Preserve")(a), + a_p, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + a = np.tile(a, 6) + a_p = np.tile(a_p, 6) + np.testing.assert_allclose( + GammaFunction(2.2, "Preserve")(a), + a_p, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + a = np.reshape(a, (2, 3)) + a_p = np.reshape(a_p, (2, 3)) + np.testing.assert_allclose( + GammaFunction(2.2, "Preserve")(a), + a_p, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + a = np.reshape(a, (2, 3, 1)) + a_p = np.reshape(a_p, (2, 3, 1)) + np.testing.assert_allclose( + GammaFunction(2.2, "Preserve")(a), + a_p, + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + a = -0.18 + a_p = 0.0 + np.testing.assert_allclose( + GammaFunction(2.2, "Clamp")(a), a_p, atol=TOLERANCE_ABSOLUTE_TESTS + ) + + a = np.tile(a, 6) + a_p = np.tile(a_p, 6) + np.testing.assert_allclose( + GammaFunction(2.2, "Clamp")(a), a_p, atol=TOLERANCE_ABSOLUTE_TESTS + ) + + a = np.reshape(a, (2, 3)) + a_p = np.reshape(a_p, (2, 3)) + np.testing.assert_allclose( + GammaFunction(2.2, "Clamp")(a), a_p, atol=TOLERANCE_ABSOLUTE_TESTS + ) + + a = np.reshape(a, (2, 3, 1)) + a_p = np.reshape(a_p, (2, 3, 1)) + np.testing.assert_allclose( + GammaFunction(2.2, "Clamp")(a), a_p, atol=TOLERANCE_ABSOLUTE_TESTS + ) + + def test__eq__(self): + g1 = GammaFunction(exponent=3, negative_number_handling="Clip") + g2 = GammaFunction(exponent=3, negative_number_handling="Clip") + g3 = GammaFunction(exponent=4, negative_number_handling="Clip") + g4 = GammaFunction(exponent=3, negative_number_handling="Mirror") + + assert g1 == g2 + assert g1 != g3 + assert g1 != g4 + + @ignore_numpy_errors + def test_nan_gamma_function(self): + """ + Test :func:`colour.models.rgb.transfer_functions.gamma.\ +gamma_function` definition nan support. + """ + + cases = [-1.0, 0.0, 1.0, -np.inf, np.inf, np.nan] + GammaFunction(cases)(cases) + + class TestGammaFunction: """ Define :func:`colour.models.rgb.transfer_functions.gamma.gamma_function`