Coverage for recovery/jakob2019.py: 77%
221 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"""
2Jakob and Hanika (2019) - Reflectance Recovery
3==============================================
5Define the objects for reflectance recovery, i.e., spectral upsampling, using
6*Jakob and Hanika (2019)* method.
8- :func:`colour.recovery.sd_Jakob2019`
9- :func:`colour.recovery.find_coefficients_Jakob2019`
10- :func:`colour.recovery.XYZ_to_sd_Jakob2019`
11- :class:`colour.recovery.LUT3D_Jakob2019`
13References
14----------
15- :cite:`Jakob2019` : Jakob, W., & Hanika, J. (2019). A Low-Dimensional
16 Function Space for Efficient Spectral Upsampling. Computer Graphics Forum,
17 38(2), 147-155. doi:10.1111/cgf.13626
18"""
20from __future__ import annotations
22import struct
23import typing
25import numpy as np
27from colour.algebra import smoothstep_function, spow
28from colour.colorimetry import (
29 MultiSpectralDistributions,
30 SpectralDistribution,
31 SpectralShape,
32 handle_spectral_arguments,
33 intermediate_lightness_function_CIE1976,
34 sd_to_XYZ_integration,
35)
36from colour.constants import DTYPE_INT_DEFAULT
37from colour.difference import JND_CIE1976
39if typing.TYPE_CHECKING:
40 from scipy.interpolate import RegularGridInterpolator
42 from colour.hints import (
43 Callable,
44 Literal,
45 PathLike,
46 Tuple,
47 )
49from colour.hints import ArrayLike, Domain1, NDArrayFloat # noqa: TC001
50from colour.models import RGB_Colourspace, RGB_to_XYZ, XYZ_to_Lab, XYZ_to_xy
51from colour.utilities import (
52 as_float_array,
53 as_float_scalar,
54 domain_range_scale,
55 full,
56 index_along_last_axis,
57 is_tqdm_installed,
58 message_box,
59 optional,
60 required,
61 to_domain_1,
62 tsplit,
63 zeros,
64)
66if is_tqdm_installed():
67 from tqdm import tqdm
68else: # pragma: no cover
69 from unittest import mock
71 tqdm = mock.MagicMock()
73__author__ = "Colour Developers"
74__copyright__ = "Copyright 2013 Colour Developers"
75__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
76__maintainer__ = "Colour Developers"
77__email__ = "colour-developers@colour-science.org"
78__status__ = "Production"
80__all__ = [
81 "SPECTRAL_SHAPE_JAKOB2019",
82 "StopMinimizationEarlyError",
83 "sd_Jakob2019",
84 "error_function",
85 "dimensionalise_coefficients",
86 "lightness_scale",
87 "find_coefficients_Jakob2019",
88 "XYZ_to_sd_Jakob2019",
89 "LUT3D_Jakob2019",
90]
92SPECTRAL_SHAPE_JAKOB2019: SpectralShape = SpectralShape(360, 780, 5)
93"""Spectral shape for *Jakob and Hanika (2019)* method."""
96class StopMinimizationEarlyError(Exception):
97 """
98 Define an exception to halt :func:`scipy.optimize.minimize` when the
99 minimized function value becomes sufficiently small.
101 *SciPy* does not currently provide a native mechanism for early
102 termination based on function value thresholds.
104 Attributes
105 ----------
106 - :attr:`~colour.recovery.jakob2019.StopMinimizationEarlyError.coefficients`
107 - :attr:`~colour.recovery.jakob2019.StopMinimizationEarlyError.error`
108 """
110 def __init__(self, coefficients: ArrayLike, error: float) -> None:
111 self._coefficients = as_float_array(coefficients)
112 self._error = as_float_scalar(error)
114 @property
115 def coefficients(self) -> NDArrayFloat:
116 """
117 Getter for the *Jakob and Hanika (2019)* exception coefficients.
119 Returns
120 -------
121 :class:`numpy.ndarray`
122 *Jakob and Hanika (2019)* exception coefficients.
123 """
125 return self._coefficients
127 @property
128 def error(self) -> float:
129 """
130 Getter for the *Jakob and Hanika (2019)* spectral upsampling error
131 value.
133 Returns
134 -------
135 :class:`float`
136 *Jakob and Hanika (2019)* spectral upsampling error value
137 representing the quality of the coefficient fitting process.
138 """
140 return self._error
143def sd_Jakob2019(
144 coefficients: ArrayLike, shape: SpectralShape = SPECTRAL_SHAPE_JAKOB2019
145) -> SpectralDistribution:
146 """
147 Generate a spectral distribution using the spectral model specified by
148 *Jakob and Hanika (2019)*.
150 Parameters
151 ----------
152 coefficients
153 Dimensionless coefficients for the *Jakob and Hanika (2019)*
154 reflectance spectral model.
155 shape
156 Shape used by the spectral distribution.
158 Returns
159 -------
160 :class:`colour.SpectralDistribution`
161 *Jakob and Hanika (2019)* spectral distribution.
163 References
164 ----------
165 :cite:`Jakob2019`
167 Examples
168 --------
169 >>> from colour.utilities import numpy_print_options
170 >>> with numpy_print_options(suppress=True):
171 ... sd_Jakob2019([-9e-05, 8.5e-02, -20], SpectralShape(400, 700, 20))
172 ... # doctest: +ELLIPSIS
173 SpectralDistribution([[ 400. , 0.3143046...],
174 [ 420. , 0.4133320...],
175 [ 440. , 0.4880034...],
176 [ 460. , 0.5279562...],
177 [ 480. , 0.5319346...],
178 [ 500. , 0.5 ...],
179 [ 520. , 0.4326202...],
180 [ 540. , 0.3373544...],
181 [ 560. , 0.2353056...],
182 [ 580. , 0.1507665...],
183 [ 600. , 0.0931332...],
184 [ 620. , 0.0577434...],
185 [ 640. , 0.0367011...],
186 [ 660. , 0.0240879...],
187 [ 680. , 0.0163316...],
188 [ 700. , 0.0114118...]],
189 SpragueInterpolator,
190 {},
191 Extrapolator,
192 {'method': 'Constant', 'left': None, 'right': None})
193 """
195 c_0, c_1, c_2 = as_float_array(coefficients)
196 wl = shape.wavelengths
197 U = c_0 * wl**2 + c_1 * wl + c_2
198 R = 1 / 2 + U / (2 * np.sqrt(1 + U**2))
200 name = f"{coefficients!r} (COEFF) - Jakob (2019)"
202 return SpectralDistribution(R, wl, name=name)
205@typing.overload
206def error_function(
207 coefficients: ArrayLike,
208 target: ArrayLike,
209 cmfs: MultiSpectralDistributions,
210 illuminant: SpectralDistribution,
211 max_error: float | None = ...,
212 additional_data: Literal[True] = True,
213) -> Tuple[float, NDArrayFloat, NDArrayFloat, NDArrayFloat, NDArrayFloat]: ...
214@typing.overload
215def error_function(
216 coefficients: ArrayLike,
217 target: ArrayLike,
218 cmfs: MultiSpectralDistributions,
219 illuminant: SpectralDistribution,
220 max_error: float | None = ...,
221 *,
222 additional_data: Literal[False],
223) -> Tuple[float, NDArrayFloat]: ...
224@typing.overload
225def error_function(
226 coefficients: ArrayLike,
227 target: ArrayLike,
228 cmfs: MultiSpectralDistributions,
229 illuminant: SpectralDistribution,
230 max_error: float | None,
231 additional_data: Literal[False],
232) -> Tuple[float, NDArrayFloat]: ...
233def error_function(
234 coefficients: ArrayLike,
235 target: ArrayLike,
236 cmfs: MultiSpectralDistributions,
237 illuminant: SpectralDistribution,
238 max_error: float | None = None,
239 additional_data: bool = False,
240) -> (
241 Tuple[float, NDArrayFloat]
242 | Tuple[float, NDArrayFloat, NDArrayFloat, NDArrayFloat, NDArrayFloat]
243):
244 """
245 Compute :math:`\\Delta E_{76}` between the target colour and the colour
246 defined by the specified spectral model, along with its gradient.
248 Parameters
249 ----------
250 coefficients
251 Dimensionless coefficients for *Jakob and Hanika (2019)*
252 reflectance spectral model.
253 target
254 *CIE L\\*a\\*b\\** colourspace array of the target colour.
255 cmfs
256 Standard observer colour matching functions.
257 illuminant
258 Illuminant spectral distribution.
259 max_error
260 Raise ``StopMinimizationEarlyError`` if the error is smaller than
261 this. The default is *None* and the function doesn't raise
262 anything.
263 additional_data
264 If *True*, some intermediate calculations are returned, for use in
265 correctness tests: R, XYZ and Lab.
267 Returns
268 -------
269 :class:`tuple` or :class:`tuple`
270 Tuple of computed :math:`\\Delta E_{76}` error and gradient of
271 error, i.e., the first derivatives of error with respect to the
272 input coefficients or tuple of computed :math:`\\Delta E_{76}`
273 error, gradient of error, computed spectral reflectance, *CIE XYZ*
274 tristimulus values corresponding to ``R`` and *CIE L\\*a\\*b\\**
275 colourspace array corresponding to ``XYZ``.
277 Raises
278 ------
279 StopMinimizationEarlyError
280 Raised when the error is below ``max_error``.
281 """
283 target = as_float_array(target)
285 c_0, c_1, c_2 = as_float_array(coefficients)
286 wv = np.linspace(0, 1, len(cmfs.shape))
288 U = c_0 * wv**2 + c_1 * wv + c_2
289 t1 = np.sqrt(1 + U**2)
290 R = 1 / 2 + U / (2 * t1)
292 t2 = 1 / (2 * t1) - U**2 / (2 * t1**3)
293 dR = np.array([wv**2 * t2, wv * t2, t2])
295 XYZ = sd_to_XYZ_integration(R, cmfs, illuminant, shape=cmfs.shape) / 100
296 dXYZ = np.transpose(
297 sd_to_XYZ_integration(dR, cmfs, illuminant, shape=cmfs.shape) / 100
298 )
300 XYZ_n = sd_to_XYZ_integration(illuminant, cmfs)
301 XYZ_n /= XYZ_n[1]
302 XYZ_XYZ_n = XYZ / XYZ_n
304 XYZ_f = intermediate_lightness_function_CIE1976(XYZ, XYZ_n)
305 dXYZ_f = np.where(
306 XYZ_XYZ_n[..., None] > (24 / 116) ** 3,
307 1 / (3 * spow(XYZ_n[..., None], 1 / 3) * spow(XYZ[..., None], 2 / 3)) * dXYZ,
308 (841 / 108) * dXYZ / XYZ_n[..., None],
309 )
311 def intermediate_XYZ_to_Lab(
312 XYZ_i: NDArrayFloat, offset: float | None = 16
313 ) -> NDArrayFloat:
314 """
315 Return the final intermediate value for the *CIE Lab* to *CIE XYZ*
316 conversion.
317 """
319 return np.array(
320 [
321 116 * XYZ_i[1] - offset,
322 500 * (XYZ_i[0] - XYZ_i[1]),
323 200 * (XYZ_i[1] - XYZ_i[2]),
324 ]
325 )
327 Lab_i = intermediate_XYZ_to_Lab(XYZ_f)
328 dLab_i = intermediate_XYZ_to_Lab(dXYZ_f, 0)
330 error = np.sqrt(np.sum((Lab_i - target) ** 2))
331 if max_error is not None and error <= max_error:
332 raise StopMinimizationEarlyError(coefficients, error)
334 derror = np.sum(dLab_i * (Lab_i[..., None] - target[..., None]), axis=0) / error
336 if additional_data:
337 return error, derror, R, XYZ, Lab_i
339 return error, derror
342def dimensionalise_coefficients(
343 coefficients: ArrayLike, shape: SpectralShape
344) -> NDArrayFloat:
345 """
346 Rescale dimensionless coefficients to the specified spectral shape.
348 A dimensionless form of the reflectance spectral model is used in the
349 optimisation process. Instead of the usual spectral shape, specified in
350 nanometres, it is normalised to the [0, 1] range. A side effect is
351 that computed coefficients work only with the normalised range and
352 need to be rescaled to regain units and be compatible with standard
353 shapes.
355 Parameters
356 ----------
357 coefficients
358 Dimensionless coefficients.
359 shape
360 Spectral distribution shape used in calculations.
362 Returns
363 -------
364 :class:`numpy.ndarray`
365 Dimensionful coefficients, with units of
366 :math:`\\frac{1}{\\mathrm{nm}^2}`, :math:`\\frac{1}{\\mathrm{nm}}`
367 and 1, respectively.
368 """
370 cp_0, cp_1, cp_2 = tsplit(coefficients)
371 span = shape.end - shape.start
373 c_0 = cp_0 / span**2
374 c_1 = cp_1 / span - 2 * cp_0 * shape.start / span**2
375 c_2 = cp_0 * shape.start**2 / span**2 - cp_1 * shape.start / span + cp_2
377 return np.array([c_0, c_1, c_2])
380def lightness_scale(steps: int) -> NDArrayFloat:
381 """
382 Generate a non-linear lightness scale as described in *Jakob and Hanika
383 (2019)*.
385 The scale reduces spacing between very dark and very bright (and
386 saturated) colours, providing finer resolution in regions where
387 coefficients change rapidly.
389 Parameters
390 ----------
391 steps
392 Number of samples along the non-linear lightness scale.
394 Returns
395 -------
396 :class:`numpy.ndarray`
397 Non-linear lightness scale array.
399 Examples
400 --------
401 >>> lightness_scale(5) # doctest: +ELLIPSIS
402 array([ 0. , 0.0656127..., 0.5 , 0.9343872..., \
4031. ])
404 """
406 linear = np.linspace(0, 1, steps)
408 return smoothstep_function(smoothstep_function(linear))
411@required("SciPy")
412def find_coefficients_Jakob2019(
413 XYZ: ArrayLike,
414 cmfs: MultiSpectralDistributions | None = None,
415 illuminant: SpectralDistribution | None = None,
416 coefficients_0: ArrayLike = (0, 0, 0),
417 max_error: float = JND_CIE1976 / 100,
418 dimensionalise: bool = True,
419) -> Tuple[NDArrayFloat, float]:
420 """
421 Find the coefficients for the *Jakob and Hanika (2019)* reflectance
422 spectral model.
424 Parameters
425 ----------
426 XYZ
427 *CIE XYZ* tristimulus values to find the coefficients for.
428 cmfs
429 Standard observer colour matching functions, default to the
430 *CIE 1931 2 Degree Standard Observer*.
431 illuminant
432 Illuminant spectral distribution, default to
433 *CIE Standard Illuminant D65*.
434 coefficients_0
435 Starting coefficients for the solver.
436 max_error
437 Maximal acceptable error. Set higher to save computational time.
438 If *None*, the solver will keep going until it is very close to
439 the minimum. The default is ``ACCEPTABLE_DELTA_E``.
440 dimensionalise
441 If *True*, returned coefficients are dimensionful and will not
442 work correctly if fed back as ``coefficients_0``. The default
443 is *True*.
445 Returns
446 -------
447 :class:`tuple`
448 Tuple of computed coefficients that best fit the specified
449 colour and :math:`\\Delta E_{76}` between the target colour and
450 the colour corresponding to the computed coefficients.
452 References
453 ----------
454 :cite:`Jakob2019`
456 Examples
457 --------
458 >>> XYZ = np.array([0.20654008, 0.12197225, 0.05136952])
459 >>> find_coefficients_Jakob2019(XYZ) # doctest: +ELLIPSIS
460 (array([ 1.3723791...e-04, -1.3514399...e-01, 3.0838973...e+01]), \
4610.0141941...)
462 """
464 from scipy.optimize import minimize # noqa: PLC0415
466 XYZ = as_float_array(XYZ)
467 coefficients_0 = as_float_array(coefficients_0)
469 cmfs, illuminant = handle_spectral_arguments(
470 cmfs, illuminant, shape_default=SPECTRAL_SHAPE_JAKOB2019
471 )
473 def optimize(
474 target_o: NDArrayFloat, coefficients_0_o: NDArrayFloat
475 ) -> Tuple[NDArrayFloat, float | np.float64]:
476 """Minimise the error function using *L-BFGS-B* method."""
478 try:
479 result = minimize(
480 error_function,
481 coefficients_0_o,
482 (target_o, cmfs, illuminant, max_error),
483 method="L-BFGS-B",
484 jac=True,
485 )
486 except StopMinimizationEarlyError as error:
487 return error.coefficients, error.error
488 else:
489 return result.x, result.fun
491 xy_n = XYZ_to_xy(sd_to_XYZ_integration(illuminant, cmfs))
493 XYZ_g = full(3, 0.5)
494 coefficients_g = zeros(3)
496 divisions = 3
497 while divisions < 10:
498 XYZ_r = XYZ_g
499 coefficient_r = coefficients_g
500 keep_divisions = False
502 coefficients_0 = coefficient_r
503 for i in range(1, divisions):
504 XYZ_i = (XYZ - XYZ_r) * i / (divisions - 1) + XYZ_r
505 Lab_i = XYZ_to_Lab(XYZ_i)
507 coefficients_0, error = optimize(Lab_i, coefficients_0)
509 if error > max_error:
510 break
511 XYZ_g = XYZ_i
512 coefficients_g = coefficients_0
513 keep_divisions = True
514 else:
515 break
517 if not keep_divisions:
518 divisions += 2
520 target = XYZ_to_Lab(XYZ, xy_n)
521 coefficients, error = optimize(target, coefficients_0)
523 if dimensionalise:
524 coefficients = dimensionalise_coefficients(coefficients, cmfs.shape)
526 return coefficients, float(error)
529@typing.overload
530def XYZ_to_sd_Jakob2019(
531 XYZ: Domain1,
532 cmfs: MultiSpectralDistributions | None = ...,
533 illuminant: SpectralDistribution | None = ...,
534 optimisation_kwargs: dict | None = ...,
535 additional_data: Literal[True] = True,
536) -> Tuple[SpectralDistribution, float]: ...
539@typing.overload
540def XYZ_to_sd_Jakob2019(
541 XYZ: Domain1,
542 cmfs: MultiSpectralDistributions | None = ...,
543 illuminant: SpectralDistribution | None = ...,
544 optimisation_kwargs: dict | None = ...,
545 *,
546 additional_data: Literal[False],
547) -> SpectralDistribution: ...
550@typing.overload
551def XYZ_to_sd_Jakob2019(
552 XYZ: Domain1,
553 cmfs: MultiSpectralDistributions | None,
554 illuminant: SpectralDistribution | None,
555 optimisation_kwargs: dict | None,
556 additional_data: Literal[False],
557) -> SpectralDistribution: ...
560def XYZ_to_sd_Jakob2019(
561 XYZ: Domain1,
562 cmfs: MultiSpectralDistributions | None = None,
563 illuminant: SpectralDistribution | None = None,
564 optimisation_kwargs: dict | None = None,
565 additional_data: bool = False,
566) -> Tuple[SpectralDistribution, float] | SpectralDistribution:
567 """
568 Recover the spectral distribution from the specified *CIE XYZ* tristimulus
569 values using *Jakob and Hanika (2019)* method.
571 Parameters
572 ----------
573 XYZ
574 *CIE XYZ* tristimulus values to recover the spectral distribution
575 from.
576 cmfs
577 Standard observer colour matching functions, default to the
578 *CIE 1931 2 Degree Standard Observer*.
579 illuminant
580 Illuminant spectral distribution, default to
581 *CIE Standard Illuminant D65*.
582 optimisation_kwargs
583 Parameters for :func:`colour.recovery.find_coefficients_Jakob2019`
584 definition.
585 additional_data
586 If *True*, ``error`` will be returned alongside the recovered
587 spectral distribution.
589 Returns
590 -------
591 :class:`tuple` or :class:`colour.SpectralDistribution`
592 Tuple of recovered spectral distribution and :math:`\\Delta E_{76}`
593 between the target colour and the colour corresponding to the
594 computed coefficients or recovered spectral distribution.
596 Notes
597 -----
598 +------------+-----------------------+---------------+
599 | **Domain** | **Scale - Reference** | **Scale - 1** |
600 +============+=======================+===============+
601 | ``XYZ`` | 1 | 1 |
602 +------------+-----------------------+---------------+
604 References
605 ----------
606 :cite:`Jakob2019`
608 Examples
609 --------
610 >>> from colour import (
611 ... CCS_ILLUMINANTS,
612 ... MSDS_CMFS,
613 ... SDS_ILLUMINANTS,
614 ... XYZ_to_sRGB,
615 ... )
616 >>> from colour.colorimetry import sd_to_XYZ_integration
617 >>> from colour.utilities import numpy_print_options # noqa: PLC0415
618 >>> XYZ = np.array([0.20654008, 0.12197225, 0.05136952])
619 >>> cmfs = (
620 ... MSDS_CMFS["CIE 1931 2 Degree Standard Observer"]
621 ... .copy()
622 ... .align(SpectralShape(360, 780, 10))
623 ... )
624 >>> illuminant = SDS_ILLUMINANTS["D65"].copy().align(cmfs.shape)
625 >>> sd = XYZ_to_sd_Jakob2019(XYZ, cmfs, illuminant)
626 >>> with numpy_print_options(suppress=True):
627 ... sd # doctest: +ELLIPSIS
628 SpectralDistribution([[ 360. , 0.4893773...],
629 [ 370. , 0.3258214...],
630 [ 380. , 0.2147792...],
631 [ 390. , 0.1482413...],
632 [ 400. , 0.1086169...],
633 [ 410. , 0.0841255...],
634 [ 420. , 0.0683114...],
635 [ 430. , 0.0577144...],
636 [ 440. , 0.0504267...],
637 [ 450. , 0.0453552...],
638 [ 460. , 0.0418520...],
639 [ 470. , 0.0395259...],
640 [ 480. , 0.0381430...],
641 [ 490. , 0.0375741...],
642 [ 500. , 0.0377685...],
643 [ 510. , 0.0387432...],
644 [ 520. , 0.0405871...],
645 [ 530. , 0.0434783...],
646 [ 540. , 0.0477225...],
647 [ 550. , 0.0538256...],
648 [ 560. , 0.0626314...],
649 [ 570. , 0.0755869...],
650 [ 580. , 0.0952675...],
651 [ 590. , 0.1264265...],
652 [ 600. , 0.1779272...],
653 [ 610. , 0.2649393...],
654 [ 620. , 0.4039779...],
655 [ 630. , 0.5832105...],
656 [ 640. , 0.7445440...],
657 [ 650. , 0.8499970...],
658 [ 660. , 0.9094792...],
659 [ 670. , 0.9425378...],
660 [ 680. , 0.9616376...],
661 [ 690. , 0.9732481...],
662 [ 700. , 0.9806562...],
663 [ 710. , 0.9855873...],
664 [ 720. , 0.9889903...],
665 [ 730. , 0.9914117...],
666 [ 740. , 0.9931801...],
667 [ 750. , 0.9945009...],
668 [ 760. , 0.9955066...],
669 [ 770. , 0.9962855...],
670 [ 780. , 0.9968976...]],
671 SpragueInterpolator,
672 {},
673 Extrapolator,
674 {'method': 'Constant', 'left': None, 'right': None})
675 >>> sd_to_XYZ_integration(sd, cmfs, illuminant) / 100 # doctest: +ELLIPSIS
676 array([ 0.2066217..., 0.1220128..., 0.0513958...])
677 """
679 XYZ = to_domain_1(XYZ)
681 cmfs, illuminant = handle_spectral_arguments(
682 cmfs, illuminant, shape_default=SPECTRAL_SHAPE_JAKOB2019
683 )
685 optimisation_kwargs = optional(optimisation_kwargs, {})
687 with domain_range_scale("ignore"):
688 coefficients, error = find_coefficients_Jakob2019(
689 XYZ, cmfs, illuminant, **optimisation_kwargs
690 )
692 sd = sd_Jakob2019(coefficients, cmfs.shape)
693 sd.name = f"{XYZ} (XYZ) - Jakob (2019)"
695 if additional_data:
696 return sd, error
698 return sd
701class LUT3D_Jakob2019:
702 r"""
703 Define a class for working with pre-computed lookup tables for the
704 *Jakob and Hanika (2019)* spectral upsampling method. This class
705 enables significant time savings by performing expensive numerical
706 optimisation ahead of time and storing the results in a file.
708 The file format is compatible with the code and *\*.coeff* files in the
709 supplemental material published alongside the article. These files are
710 directly available from
711 `Colour - Datasets <https://github.com/colour-science/colour-datasets>`__
712 under the record *4050598*.
714 Attributes
715 ----------
716 - :attr:`~colour.recovery.LUT3D_Jakob2019.size`
717 - :attr:`~colour.recovery.LUT3D_Jakob2019.lightness_scale`
718 - :attr:`~colour.recovery.LUT3D_Jakob2019.coefficients`
719 - :attr:`~colour.recovery.LUT3D_Jakob2019.interpolator`
721 Methods
722 -------
723 - :meth:`~colour.recovery.LUT3D_Jakob2019.__init__`
724 - :meth:`~colour.recovery.LUT3D_Jakob2019.generate`
725 - :meth:`~colour.recovery.LUT3D_Jakob2019.RGB_to_coefficients`
726 - :meth:`~colour.recovery.LUT3D_Jakob2019.RGB_to_sd`
727 - :meth:`~colour.recovery.LUT3D_Jakob2019.read`
728 - :meth:`~colour.recovery.LUT3D_Jakob2019.write`
730 References
731 ----------
732 :cite:`Jakob2019`
734 Examples
735 --------
736 >>> import os
737 >>> import colour
738 >>> from colour import CCS_ILLUMINANTS, SDS_ILLUMINANTS, MSDS_CMFS
739 >>> from colour.colorimetry import sd_to_XYZ_integration
740 >>> from colour.models import RGB_COLOURSPACE_sRGB
741 >>> from colour.utilities import numpy_print_options
742 >>> cmfs = (
743 ... MSDS_CMFS["CIE 1931 2 Degree Standard Observer"]
744 ... .copy()
745 ... .align(SpectralShape(360, 780, 10))
746 ... )
747 >>> illuminant = SDS_ILLUMINANTS["D65"].copy().align(cmfs.shape)
748 >>> LUT = LUT3D_Jakob2019()
749 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3, lambda x: x)
750 >>> path = os.path.join(
751 ... colour.__path__[0],
752 ... "recovery",
753 ... "tests",
754 ... "resources",
755 ... "sRGB_Jakob2019.coeff",
756 ... )
757 >>> LUT.write(path) # doctest: +SKIP
758 >>> LUT.read(path) # doctest: +SKIP
759 >>> RGB = np.array([0.70573936, 0.19248266, 0.22354169])
760 >>> with numpy_print_options(suppress=True):
761 ... LUT.RGB_to_sd(RGB, cmfs.shape) # doctest: +ELLIPSIS
762 SpectralDistribution([[ 360. , 0.7666803...],
763 [ 370. , 0.6251547...],
764 [ 380. , 0.4584310...],
765 [ 390. , 0.3161633...],
766 [ 400. , 0.2196155...],
767 [ 410. , 0.1596575...],
768 [ 420. , 0.1225525...],
769 [ 430. , 0.0989784...],
770 [ 440. , 0.0835782...],
771 [ 450. , 0.0733535...],
772 [ 460. , 0.0666049...],
773 [ 470. , 0.0623569...],
774 [ 480. , 0.06006 ...],
775 [ 490. , 0.0594383...],
776 [ 500. , 0.0604201...],
777 [ 510. , 0.0631195...],
778 [ 520. , 0.0678648...],
779 [ 530. , 0.0752834...],
780 [ 540. , 0.0864790...],
781 [ 550. , 0.1033773...],
782 [ 560. , 0.1293883...],
783 [ 570. , 0.1706018...],
784 [ 580. , 0.2374178...],
785 [ 590. , 0.3439472...],
786 [ 600. , 0.4950548...],
787 [ 610. , 0.6604253...],
788 [ 620. , 0.7914669...],
789 [ 630. , 0.8738724...],
790 [ 640. , 0.9213216...],
791 [ 650. , 0.9486880...],
792 [ 660. , 0.9650550...],
793 [ 670. , 0.9752838...],
794 [ 680. , 0.9819499...],
795 [ 690. , 0.9864585...],
796 [ 700. , 0.9896073...],
797 [ 710. , 0.9918680...],
798 [ 720. , 0.9935302...],
799 [ 730. , 0.9947778...],
800 [ 740. , 0.9957312...],
801 [ 750. , 0.9964714...],
802 [ 760. , 0.9970543...],
803 [ 770. , 0.9975190...],
804 [ 780. , 0.9978936...]],
805 SpragueInterpolator,
806 {},
807 Extrapolator,
808 {'method': 'Constant', 'left': None, 'right': None})
809 """
811 def __init__(self) -> None:
812 from scipy.interpolate import RegularGridInterpolator # noqa: PLC0415
814 self._interpolator = RegularGridInterpolator((), np.array([]))
816 self._size: int = 0
817 self._lightness_scale: NDArrayFloat = np.array([])
818 self._coefficients: NDArrayFloat = np.array([])
820 @property
821 def size(self) -> int:
822 """
823 Getter for the *Jakob and Hanika (2019)* interpolator size.
825 The size represents the sample count on one side of the 3D lookup
826 table used for spectral upsampling.
828 Returns
829 -------
830 :class:`int`
831 *Jakob and Hanika (2019)* interpolator size.
832 """
834 return self._size
836 @property
837 def lightness_scale(self) -> NDArrayFloat:
838 """
839 Getter for the *Jakob and Hanika (2019)* interpolator lightness
840 scale.
842 Returns
843 -------
844 :class:`numpy.ndarray`
845 *Jakob and Hanika (2019)* interpolator lightness scale.
846 """
848 return self._lightness_scale
850 @property
851 def coefficients(self) -> NDArrayFloat:
852 """
853 Getter for the *Jakob and Hanika (2019)* interpolator coefficients.
855 Returns
856 -------
857 :class:`numpy.ndarray`
858 *Jakob and Hanika (2019)* interpolator coefficients.
859 """
861 return self._coefficients
863 @property
864 def interpolator(self) -> RegularGridInterpolator:
865 """
866 Getter for the *Jakob and Hanika (2019)* interpolator.
868 Returns
869 -------
870 :class:`scipy.interpolate.RegularGridInterpolator`
871 *Jakob and Hanika (2019)* interpolator.
872 """
874 return self._interpolator
876 def _create_interpolator(self) -> None:
877 """
878 Create a :class:`scipy.interpolate.RegularGridInterpolator` class
879 instance for read or generated coefficients.
880 """
882 from scipy.interpolate import RegularGridInterpolator # noqa: PLC0415
884 samples = np.linspace(0, 1, self._size)
885 axes = ([0, 1, 2], self._lightness_scale, samples, samples)
887 self._interpolator = RegularGridInterpolator(
888 axes, self._coefficients, bounds_error=False
889 )
891 def generate(
892 self,
893 colourspace: RGB_Colourspace,
894 cmfs: MultiSpectralDistributions | None = None,
895 illuminant: SpectralDistribution | None = None,
896 size: int = 64,
897 print_callable: Callable = print,
898 ) -> None:
899 """
900 Generate the lookup table data for the specified *RGB* colourspace,
901 colour matching functions, illuminant and resolution.
903 Parameters
904 ----------
905 colourspace
906 The *RGB* colourspace to create a lookup table for.
907 cmfs
908 Standard observer colour matching functions, default to the
909 *CIE 1931 2 Degree Standard Observer*.
910 illuminant
911 Illuminant spectral distribution, default to
912 *CIE Standard Illuminant D65*.
913 size
914 The resolution of the lookup table. Higher values will decrease
915 errors but at the cost of a much longer run time. The published
916 *\\*.coeff* files have a resolution of 64.
917 print_callable
918 Callable used to print progress and diagnostic information.
920 Examples
921 --------
922 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS
923 >>> from colour.models import RGB_COLOURSPACE_sRGB
924 >>> from colour.utilities import numpy_print_options
925 >>> cmfs = (
926 ... MSDS_CMFS["CIE 1931 2 Degree Standard Observer"]
927 ... .copy()
928 ... .align(SpectralShape(360, 780, 10))
929 ... )
930 >>> illuminant = SDS_ILLUMINANTS["D65"].copy().align(cmfs.shape)
931 >>> LUT = LUT3D_Jakob2019()
932 >>> print(LUT.interpolator) # doctest: +ELLIPSIS
933 <scipy...RegularGridInterpolator object at 0x...>
934 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3)
935 ======================================================================\
936=========
937 * \
938 *
939 * "Jakob et al. (2018)" LUT Optimisation \
940 *
941 * \
942 *
943 ======================================================================\
944=========
945 <BLANKLINE>
946 Optimising 27 coefficients...
947 <BLANKLINE>
948 >>> print(LUT.interpolator)
949 ... # doctest: +ELLIPSIS
950 <scipy.interpolate...RegularGridInterpolator object at 0x...>
951 """
953 cmfs, illuminant = handle_spectral_arguments(
954 cmfs, illuminant, shape_default=SPECTRAL_SHAPE_JAKOB2019
955 )
956 shape = cmfs.shape
958 xy_n = XYZ_to_xy(sd_to_XYZ_integration(illuminant, cmfs))
960 # It could be interesting to have different resolutions for lightness
961 # and chromaticity, but the current file format doesn't allow it.
962 lightness_steps = size
963 chroma_steps = size
965 self._lightness_scale = lightness_scale(lightness_steps)
966 self._coefficients = np.empty(
967 [3, chroma_steps, chroma_steps, lightness_steps, 3]
968 )
970 cube_indexes = np.ndindex(3, chroma_steps, chroma_steps)
971 total_coefficients = chroma_steps**2 * 3
973 # First, create a list of all the fully bright colours with the order
974 # matching cube_indexes.
975 samples = np.linspace(0, 1, chroma_steps)
976 ij = np.reshape(
977 np.transpose(np.meshgrid([1], samples, samples, indexing="ij")),
978 (-1, 3),
979 )
980 chromas = np.concatenate(
981 [
982 ij,
983 np.roll(ij, 1, axis=1),
984 np.roll(ij, 2, axis=1),
985 ]
986 )
988 message_box(
989 '"Jakob et al. (2018)" LUT Optimisation',
990 print_callable=print_callable,
991 )
993 print_callable(f"\nOptimising {total_coefficients} coefficients...\n")
995 def optimize(
996 ijkL: ArrayLike, coefficients_0: ArrayLike, chroma: NDArrayFloat
997 ) -> NDArrayFloat:
998 """
999 Solve for a specific lightness and stores the result in the
1000 appropriate cell.
1001 """
1003 i, j, k, L = tsplit(ijkL, dtype=DTYPE_INT_DEFAULT)
1005 RGB = self._lightness_scale[L] * chroma
1007 XYZ = RGB_to_XYZ(RGB, colourspace, xy_n)
1009 coefficients, _error = find_coefficients_Jakob2019(
1010 XYZ, cmfs, illuminant, coefficients_0, dimensionalise=False
1011 )
1013 self._coefficients[i, L, j, k, :] = dimensionalise_coefficients(
1014 coefficients, shape
1015 )
1017 return coefficients
1019 with tqdm(total=total_coefficients) as progress:
1020 for ijk, chroma in zip(cube_indexes, chromas, strict=True):
1021 progress.update()
1023 # Starts from somewhere in the middle, similarly to how
1024 # feedback works in "colour.recovery.\
1025 # find_coefficients_Jakob2019" definition.
1026 L_middle = lightness_steps // 3
1027 coefficients_middle = optimize(
1028 np.hstack([ijk, L_middle]), zeros(3), chroma
1029 )
1031 # Down the lightness scale.
1032 coefficients_0 = coefficients_middle
1033 for L in reversed(range(L_middle)):
1034 coefficients_0 = optimize(
1035 np.hstack([ijk, L]), coefficients_0, chroma
1036 )
1038 # Up the lightness scale.
1039 coefficients_0 = coefficients_middle
1040 for L in range(L_middle + 1, lightness_steps):
1041 coefficients_0 = optimize(
1042 np.hstack([ijk, L]), coefficients_0, chroma
1043 )
1045 self._size = size
1046 self._create_interpolator()
1048 def RGB_to_coefficients(self, RGB: ArrayLike) -> NDArrayFloat:
1049 """
1050 Look up the specified *RGB* colourspace array and return the
1051 corresponding coefficients.
1053 Interpolation is used for colours not on the table grid.
1055 Parameters
1056 ----------
1057 RGB
1058 *RGB* colourspace array.
1060 Returns
1061 -------
1062 :class:`numpy.ndarray`
1063 Corresponding coefficients that can be passed to
1064 :func:`colour.recovery.jakob2019.sd_Jakob2019` to obtain a
1065 spectral distribution.
1067 Raises
1068 ------
1069 RuntimeError
1070 If the pre-computed lookup table has not been generated or read.
1072 Examples
1073 --------
1074 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS
1075 >>> from colour.models import RGB_COLOURSPACE_sRGB
1076 >>> cmfs = (
1077 ... MSDS_CMFS["CIE 1931 2 Degree Standard Observer"]
1078 ... .copy()
1079 ... .align(SpectralShape(360, 780, 10))
1080 ... )
1081 >>> illuminant = SDS_ILLUMINANTS["D65"].copy().align(cmfs.shape)
1082 >>> LUT = LUT3D_Jakob2019()
1083 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3, lambda x: x)
1084 >>> RGB = np.array([0.70573936, 0.19248266, 0.22354169])
1085 >>> LUT.RGB_to_coefficients(RGB) # doctest: +ELLIPSIS
1086 array([ 1.5013448...e-04, -1.4679754...e-01, 3.4020219...e+01])
1087 """
1089 if len(self._interpolator.grid) != 0:
1090 RGB = as_float_array(RGB)
1092 value_max = np.max(RGB, axis=-1)
1093 chroma = RGB / (value_max[..., None] + 1e-10)
1095 i_m = np.argmax(RGB, axis=-1)
1096 i_1 = index_along_last_axis(RGB, i_m)
1097 i_2 = index_along_last_axis(chroma, (i_m + 2) % 3)
1098 i_3 = index_along_last_axis(chroma, (i_m + 1) % 3)
1100 indexes = np.stack([i_m, i_1, i_2, i_3], axis=-1)
1102 return self._interpolator(indexes).squeeze()
1104 error = "The pre-computed lookup table has not been read or generated!"
1106 raise RuntimeError(error)
1108 def RGB_to_sd(
1109 self, RGB: ArrayLike, shape: SpectralShape = SPECTRAL_SHAPE_JAKOB2019
1110 ) -> SpectralDistribution:
1111 """
1112 Look up a specified *RGB* colourspace array and return the
1113 corresponding spectral distribution.
1115 Parameters
1116 ----------
1117 RGB
1118 *RGB* colourspace array.
1119 shape
1120 Shape used by the spectral distribution.
1122 Returns
1123 -------
1124 :class:`colour.SpectralDistribution`
1125 Spectral distribution corresponding with the *RGB* colourspace
1126 array.
1128 Examples
1129 --------
1130 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS
1131 >>> from colour.models import RGB_COLOURSPACE_sRGB
1132 >>> from colour.utilities import numpy_print_options
1133 >>> cmfs = (
1134 ... MSDS_CMFS["CIE 1931 2 Degree Standard Observer"]
1135 ... .copy()
1136 ... .align(SpectralShape(360, 780, 10))
1137 ... )
1138 >>> illuminant = SDS_ILLUMINANTS["D65"].copy().align(cmfs.shape)
1139 >>> LUT = LUT3D_Jakob2019()
1140 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3, lambda x: x)
1141 >>> RGB = np.array([0.70573936, 0.19248266, 0.22354169])
1142 >>> with numpy_print_options(suppress=True):
1143 ... LUT.RGB_to_sd(RGB, cmfs.shape) # doctest: +ELLIPSIS
1144 SpectralDistribution([[ 360. , 0.7666803...],
1145 [ 370. , 0.6251547...],
1146 [ 380. , 0.4584310...],
1147 [ 390. , 0.3161633...],
1148 [ 400. , 0.2196155...],
1149 [ 410. , 0.1596575...],
1150 [ 420. , 0.1225525...],
1151 [ 430. , 0.0989784...],
1152 [ 440. , 0.0835782...],
1153 [ 450. , 0.0733535...],
1154 [ 460. , 0.0666049...],
1155 [ 470. , 0.0623569...],
1156 [ 480. , 0.06006 ...],
1157 [ 490. , 0.0594383...],
1158 [ 500. , 0.0604201...],
1159 [ 510. , 0.0631195...],
1160 [ 520. , 0.0678648...],
1161 [ 530. , 0.0752834...],
1162 [ 540. , 0.0864790...],
1163 [ 550. , 0.1033773...],
1164 [ 560. , 0.1293883...],
1165 [ 570. , 0.1706018...],
1166 [ 580. , 0.2374178...],
1167 [ 590. , 0.3439472...],
1168 [ 600. , 0.4950548...],
1169 [ 610. , 0.6604253...],
1170 [ 620. , 0.7914669...],
1171 [ 630. , 0.8738724...],
1172 [ 640. , 0.9213216...],
1173 [ 650. , 0.9486880...],
1174 [ 660. , 0.9650550...],
1175 [ 670. , 0.9752838...],
1176 [ 680. , 0.9819499...],
1177 [ 690. , 0.9864585...],
1178 [ 700. , 0.9896073...],
1179 [ 710. , 0.9918680...],
1180 [ 720. , 0.9935302...],
1181 [ 730. , 0.9947778...],
1182 [ 740. , 0.9957312...],
1183 [ 750. , 0.9964714...],
1184 [ 760. , 0.9970543...],
1185 [ 770. , 0.9975190...],
1186 [ 780. , 0.9978936...]],
1187 SpragueInterpolator,
1188 {},
1189 Extrapolator,
1190 {'method': 'Constant', 'left': None, 'right': None})
1191 """
1193 sd = sd_Jakob2019(self.RGB_to_coefficients(RGB), shape)
1194 sd.name = f"{RGB!r} (RGB) - Jakob (2019)"
1196 return sd
1198 def read(self, path: str | PathLike) -> LUT3D_Jakob2019:
1199 """
1200 Load a lookup table from a *\\*.coeff* file.
1202 Parameters
1203 ----------
1204 path
1205 Path to the file.
1207 Returns
1208 -------
1209 LUT3D_Jakob2019
1210 *Jakob and Hanika (2019)* lookup table.
1212 Examples
1213 --------
1214 >>> import os
1215 >>> import colour
1216 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS
1217 >>> from colour.models import RGB_COLOURSPACE_sRGB
1218 >>> from colour.utilities import numpy_print_options
1219 >>> cmfs = (
1220 ... MSDS_CMFS["CIE 1931 2 Degree Standard Observer"]
1221 ... .copy()
1222 ... .align(SpectralShape(360, 780, 10))
1223 ... )
1224 >>> illuminant = SDS_ILLUMINANTS["D65"].copy().align(cmfs.shape)
1225 >>> LUT = LUT3D_Jakob2019()
1226 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3, lambda x: x)
1227 >>> path = os.path.join(
1228 ... colour.__path__[0],
1229 ... "recovery",
1230 ... "tests",
1231 ... "resources",
1232 ... "sRGB_Jakob2019.coeff",
1233 ... )
1234 >>> LUT.write(path) # doctest: +SKIP
1235 >>> LUT.read(path) # doctest: +SKIP
1236 """
1238 path = str(path)
1240 with open(path, "rb") as coeff_file:
1241 if coeff_file.read(4).decode("ISO-8859-1") != "SPEC":
1242 error = "Bad magic number, this is likely not the right file type!"
1244 raise ValueError(error)
1246 self._size = struct.unpack("i", coeff_file.read(4))[0]
1247 self._lightness_scale = np.fromfile(
1248 coeff_file, count=self._size, dtype=np.float32
1249 )
1250 self._coefficients = np.fromfile(
1251 coeff_file, count=3 * (self._size**3) * 3, dtype=np.float32
1252 )
1253 self._coefficients = np.reshape(
1254 self._coefficients, (3, self._size, self._size, self._size, 3)
1255 )
1257 self._create_interpolator()
1259 return self
1261 def write(self, path: str | PathLike) -> bool:
1262 """
1263 Write the lookup table to a *\\*.coeff* file.
1265 Parameters
1266 ----------
1267 path
1268 Path to the file.
1270 Returns
1271 -------
1272 :class:`bool`
1273 Definition success.
1275 Examples
1276 --------
1277 >>> import os
1278 >>> import colour
1279 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS
1280 >>> from colour.models import RGB_COLOURSPACE_sRGB
1281 >>> from colour.utilities import numpy_print_options
1282 >>> cmfs = (
1283 ... MSDS_CMFS["CIE 1931 2 Degree Standard Observer"]
1284 ... .copy()
1285 ... .align(SpectralShape(360, 780, 10))
1286 ... )
1287 >>> illuminant = SDS_ILLUMINANTS["D65"].copy().align(cmfs.shape)
1288 >>> LUT = LUT3D_Jakob2019()
1289 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3, lambda x: x)
1290 >>> path = os.path.join(
1291 ... colour.__path__[0],
1292 ... "recovery",
1293 ... "tests",
1294 ... "resources",
1295 ... "sRGB_Jakob2019.coeff",
1296 ... )
1297 >>> LUT.write(path) # doctest: +SKIP
1298 >>> LUT.read(path) # doctest: +SKIP
1299 """
1301 path = str(path)
1303 with open(path, "wb") as coeff_file:
1304 coeff_file.write(b"SPEC")
1305 coeff_file.write(struct.pack("i", self._coefficients.shape[1]))
1306 np.float32(self._lightness_scale).tofile(coeff_file)
1307 np.float32(self._coefficients).tofile(coeff_file)
1309 return True