Coverage for recovery/tests/test_jakob2019.py: 100%
123 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
1"""Define the unit tests for the :mod:`colour.recovery.jakob2019` module."""
3from __future__ import annotations
5import os
6import shutil
7import tempfile
8import typing
10import numpy as np
11import pytest
13from colour.characterisation import SDS_COLOURCHECKERS
14from colour.colorimetry import handle_spectral_arguments, sd_to_XYZ
15from colour.constants import TOLERANCE_ABSOLUTE_TESTS
16from colour.difference import JND_CIE1976, delta_E_CIE1976
18if typing.TYPE_CHECKING:
19 from colour.hints import Type
21from colour.models import RGB_COLOURSPACE_sRGB, RGB_to_XYZ, XYZ_to_Lab, XYZ_to_xy
22from colour.recovery.jakob2019 import (
23 SPECTRAL_SHAPE_JAKOB2019,
24 LUT3D_Jakob2019,
25 XYZ_to_sd_Jakob2019,
26 dimensionalise_coefficients,
27 error_function,
28 sd_Jakob2019,
29)
30from colour.utilities import domain_range_scale, full, is_scipy_installed, ones, zeros
32__author__ = "Colour Developers"
33__copyright__ = "Copyright 2013 Colour Developers"
34__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
35__maintainer__ = "Colour Developers"
36__email__ = "colour-developers@colour-science.org"
37__status__ = "Production"
39__all__ = [
40 "TestErrorFunction",
41 "TestXYZ_to_sd_Jakob2019",
42 "TestLUT3D_Jakob2019",
43]
46class TestErrorFunction:
47 """
48 Define :func:`colour.recovery.jakob2019.error_function` definition unit
49 tests methods.
50 """
52 def setup_method(self) -> None:
53 """Initialise the common tests attributes."""
55 self._shape = SPECTRAL_SHAPE_JAKOB2019
56 self._cmfs, self._sd_D65 = handle_spectral_arguments(shape_default=self._shape)
57 self._XYZ_D65 = sd_to_XYZ(self._sd_D65)
58 self._xy_D65 = XYZ_to_xy(self._XYZ_D65)
60 self._Lab_e = np.array([72, -20, 61])
62 def test_intermediates(self) -> None:
63 """
64 Test intermediate results of
65 :func:`colour.recovery.jakob2019.error_function` with
66 :func:`colour.sd_to_XYZ`, :func:`colour.XYZ_to_Lab` and checks if the
67 error is computed correctly by comparing it with
68 :func:`colour.difference.delta_E_CIE1976`.
69 """
71 # Quoted names refer to colours from ColorChecker N Ohta (using D65).
72 coefficient_list = [
73 np.array([0, 0, 0]), # 50% gray
74 np.array([0, 0, -1e9]), # Pure black
75 np.array([0, 0, +1e9]), # Pure white
76 np.array([1e9, -1e9, 2.1e8]), # A pathological example
77 np.array([2.2667394, -7.6313081, 1.03185]), # 'blue'
78 np.array([-31.377077, 26.810094, -6.1139927]), # 'green'
79 np.array([25.064246, -16.072039, 0.10431365]), # 'red'
80 np.array([-19.325667, 22.242319, -5.8144924]), # 'yellow'
81 np.array([21.909902, -17.227963, 2.142351]), # 'magenta'
82 np.array([-15.864009, 8.6735071, -1.4012552]), # 'cyan'
83 ]
85 for coefficients in coefficient_list:
86 error, _derror, R, XYZ, Lab = error_function(
87 coefficients,
88 self._Lab_e,
89 self._cmfs,
90 self._sd_D65,
91 additional_data=True,
92 )
94 sd = sd_Jakob2019(
95 dimensionalise_coefficients(coefficients, self._shape),
96 self._shape,
97 )
99 sd_XYZ = sd_to_XYZ(sd, self._cmfs, self._sd_D65) / 100
100 sd_Lab = XYZ_to_Lab(XYZ, self._xy_D65)
101 error_reference = delta_E_CIE1976(self._Lab_e, Lab)
103 np.testing.assert_allclose(sd.values, R, atol=TOLERANCE_ABSOLUTE_TESTS)
104 np.testing.assert_allclose(XYZ, sd_XYZ, atol=TOLERANCE_ABSOLUTE_TESTS)
106 assert abs(error_reference - error) < JND_CIE1976 / 100
107 assert delta_E_CIE1976(Lab, sd_Lab) < JND_CIE1976 / 100
109 def test_derivatives(self) -> None:
110 """
111 Test the gradients computed using closed-form expressions of the
112 derivatives with finite difference approximations.
113 """
115 samples = np.linspace(-10, 10, 1000)
116 ds = samples[1] - samples[0]
118 # Vary one coefficient at a time, keeping the others fixed to 1.
119 for coefficient_i in range(3):
120 errors = np.empty_like(samples)
121 derrors = np.empty_like(samples)
123 for i, sample in enumerate(samples):
124 coefficients = ones(3)
125 coefficients[coefficient_i] = sample
127 error, derror = error_function( # pyright: ignore
128 coefficients, self._Lab_e, self._cmfs, self._sd_D65
129 )
131 errors[i] = error
132 derrors[i] = derror[coefficient_i]
134 staggered_derrors = (derrors[:-1] + derrors[1:]) / 2
135 approximate_derrors = np.diff(errors) / ds
137 # The approximated derivatives aren't too accurate, so tolerances
138 # have to be rather loose.
139 np.testing.assert_allclose(
140 staggered_derrors, approximate_derrors, atol=5e-3
141 )
144class TestXYZ_to_sd_Jakob2019:
145 """
146 Define :func:`colour.recovery.jakob2019.XYZ_to_sd_Jakob2019` definition
147 unit tests methods.
148 """
150 def setup_method(self) -> None:
151 """Initialise the common tests attributes."""
153 self._shape = SPECTRAL_SHAPE_JAKOB2019
154 self._cmfs, self._sd_D65 = handle_spectral_arguments(shape_default=self._shape)
156 def test_XYZ_to_sd_Jakob2019(self) -> None:
157 """Test :func:`colour.recovery.jakob2019.XYZ_to_sd_Jakob2019` definition."""
159 if not is_scipy_installed(): # pragma: no cover
160 return
162 # Tests the round-trip with values of a colour checker.
163 for name, sd in SDS_COLOURCHECKERS["ColorChecker N Ohta"].items():
164 XYZ = sd_to_XYZ(sd, self._cmfs, self._sd_D65) / 100
166 _recovered_sd, error = XYZ_to_sd_Jakob2019(
167 XYZ, self._cmfs, self._sd_D65, additional_data=True
168 )
170 if error > JND_CIE1976 / 100: # pragma: no cover
171 pytest.fail(f"Delta E for '{name}' is {error}!")
173 def test_domain_range_scale_XYZ_to_sd_Jakob2019(self) -> None:
174 """
175 Test :func:`colour.recovery.jakob2019.XYZ_to_sd_Jakob2019` definition
176 domain and range scale support.
177 """
179 if not is_scipy_installed(): # pragma: no cover
180 return
182 XYZ_i = np.array([0.20654008, 0.12197225, 0.05136952])
183 XYZ_o = sd_to_XYZ(
184 XYZ_to_sd_Jakob2019(XYZ_i, self._cmfs, self._sd_D65, additional_data=False),
185 self._cmfs,
186 self._sd_D65,
187 )
189 d_r = (("reference", 1, 1), ("1", 1, 0.01), ("100", 100, 1))
190 for scale, factor_a, factor_b in d_r:
191 with domain_range_scale(scale):
192 np.testing.assert_allclose(
193 sd_to_XYZ(
194 XYZ_to_sd_Jakob2019(
195 XYZ_i * factor_a,
196 self._cmfs,
197 self._sd_D65,
198 additional_data=False,
199 ),
200 self._cmfs,
201 self._sd_D65,
202 ),
203 XYZ_o * factor_b,
204 atol=TOLERANCE_ABSOLUTE_TESTS,
205 )
208class TestLUT3D_Jakob2019:
209 """
210 Define :class:`colour.recovery.jakob2019.LUT3D_Jakob2019` definition unit
211 tests methods.
212 """
214 @classmethod
215 def generate_LUT(cls: Type[TestLUT3D_Jakob2019]) -> LUT3D_Jakob2019:
216 """
217 Generate the *LUT* used for the unit tests.
219 Notes
220 -----
221 - This method is used to define the slow common tests attributes once
222 rather than using the typical :meth:`unittest.TestCase.setUp` that
223 is invoked once per-test.
224 """
226 if not hasattr(cls, "_LUT"):
227 cls._shape = SPECTRAL_SHAPE_JAKOB2019
228 cls._cmfs, cls._sd_D65 = handle_spectral_arguments(shape_default=cls._shape)
229 cls._XYZ_D65 = sd_to_XYZ(cls._sd_D65)
230 cls._xy_D65 = XYZ_to_xy(cls._XYZ_D65)
232 cls._RGB_colourspace = RGB_COLOURSPACE_sRGB
234 cls._LUT = LUT3D_Jakob2019()
235 cls._LUT.generate(cls._RGB_colourspace, cls._cmfs, cls._sd_D65, 5)
237 return cls._LUT
239 def test_required_attributes(self) -> None:
240 """Test the presence of required attributes."""
242 required_attributes = (
243 "size",
244 "lightness_scale",
245 "coefficients",
246 "interpolator",
247 )
249 for attribute in required_attributes:
250 assert attribute in dir(LUT3D_Jakob2019)
252 def test_required_methods(self) -> None:
253 """Test the presence of required methods."""
255 required_methods = (
256 "__init__",
257 "generate",
258 "RGB_to_coefficients",
259 "RGB_to_sd",
260 "read",
261 "write",
262 )
264 for method in required_methods:
265 assert method in dir(LUT3D_Jakob2019)
267 def test_size(self) -> None:
268 """Test :attr:`colour.recovery.jakob2019.LUT3D_Jakob2019.size` property."""
270 if not is_scipy_installed(): # pragma: no cover
271 return
273 assert TestLUT3D_Jakob2019.generate_LUT().size == 5
275 def test_lightness_scale(self) -> None:
276 """
277 Test :attr:`colour.recovery.jakob2019.LUT3D_Jakob2019.lightness_scale`
278 property.
279 """
281 np.testing.assert_allclose(
282 TestLUT3D_Jakob2019.generate_LUT().lightness_scale,
283 np.array([0.00000000, 0.06561279, 0.50000000, 0.93438721, 1.00000000]),
284 atol=TOLERANCE_ABSOLUTE_TESTS,
285 )
287 def test_coefficients(self) -> None:
288 """
289 Test :attr:`colour.recovery.jakob2019.LUT3D_Jakob2019.coefficients`
290 property.
291 """
293 assert TestLUT3D_Jakob2019.generate_LUT().coefficients.shape == (3, 5, 5, 5, 3)
295 def test_interpolator(self) -> None:
296 """
297 Test :attr:`colour.recovery.jakob2019.LUT3D_Jakob2019.interpolator`
298 property.
299 """
301 if not is_scipy_installed(): # pragma: no cover
302 return
304 from scipy.interpolate import RegularGridInterpolator # noqa: PLC0415
306 interpolator = TestLUT3D_Jakob2019.generate_LUT().interpolator
307 assert isinstance(interpolator, RegularGridInterpolator)
309 def test_LUT3D_Jakob2019(self) -> None:
310 """
311 Test the entirety of the
312 :class:`colour.recovery.jakob2019.LUT3D_Jakob2019`class.
313 """
315 if not is_scipy_installed(): # pragma: no cover
316 return
318 LUT = TestLUT3D_Jakob2019.generate_LUT()
320 temporary_directory = tempfile.mkdtemp()
321 path = os.path.join(temporary_directory, "Test_Jakob2019.coeff")
323 try:
324 LUT.write(path)
325 LUT_t = LUT3D_Jakob2019().read(path)
327 np.testing.assert_allclose(
328 LUT.lightness_scale,
329 LUT_t.lightness_scale,
330 atol=TOLERANCE_ABSOLUTE_TESTS,
331 )
332 np.testing.assert_allclose(
333 LUT.coefficients,
334 LUT_t.coefficients,
335 atol=1e-6,
336 )
337 finally:
338 shutil.rmtree(temporary_directory)
340 for RGB in [
341 np.array([1, 0, 0]),
342 np.array([0, 1, 0]),
343 np.array([0, 0, 1]),
344 zeros(3),
345 full(3, 0.5),
346 ones(3),
347 ]:
348 XYZ = RGB_to_XYZ(RGB, self._RGB_colourspace, self._xy_D65)
349 Lab = XYZ_to_Lab(XYZ, self._xy_D65)
351 recovered_sd = LUT.RGB_to_sd(RGB)
352 recovered_XYZ = sd_to_XYZ(recovered_sd, self._cmfs, self._sd_D65) / 100
353 recovered_Lab = XYZ_to_Lab(recovered_XYZ, self._xy_D65)
355 error = delta_E_CIE1976(Lab, recovered_Lab)
357 if error > 2 * JND_CIE1976 / 100: # pragma: no cover
358 pytest.fail(
359 f"Delta E for RGB={RGB} in colourspace "
360 f"{self._RGB_colourspace.name} is {error}!"
361 )
363 def test_raise_exception_RGB_to_coefficients(self) -> None:
364 """
365 Test :func:`colour.recovery.jakob2019.LUT3D_Jakob2019.\
366RGB_to_coefficients` method raised exception.
367 """
369 LUT = LUT3D_Jakob2019()
371 pytest.raises(RuntimeError, LUT.RGB_to_coefficients, np.array([1, 2, 3, 4]))
373 def test_raise_exception_read(self) -> None:
374 """
375 Test :func:`colour.recovery.jakob2019.LUT3D_Jakob2019.read` method
376 raised exception.
377 """
379 LUT = LUT3D_Jakob2019()
380 pytest.raises(ValueError, LUT.read, __file__)