Coverage for io/luts/lut.py: 65%
370 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"""
2LUT Processing
3==============
5Define the classes and definitions for *Look-Up Table* (*LUT*) processing
6operations.
8- :class:`colour.LUT1D`: One-dimensional lookup table for single-channel
9 transformations
10- :class:`colour.LUT3x1D`: Three parallel one-dimensional lookup tables
11 for independent RGB channel processing
12- :class:`colour.LUT3D`: Three-dimensional lookup table for complex colour
13 space transformations
14- :class:`colour.io.LUT_to_LUT`: Utility for converting between different
15 LUT formats and types
16"""
18from __future__ import annotations
20import typing
21from abc import ABC, abstractmethod
22from copy import deepcopy
23from operator import pow # noqa: A004
24from operator import add, iadd, imul, ipow, isub, itruediv, mul, sub, truediv
26import numpy as np
28from colour.algebra import (
29 Extrapolator,
30 LinearInterpolator,
31 linear_conversion,
32 table_interpolation_trilinear,
33)
34from colour.constants import EPSILON
36if typing.TYPE_CHECKING:
37 from colour.hints import (
38 Any,
39 ArrayLike,
40 Literal,
41 NDArrayFloat,
42 Self,
43 Sequence,
44 Type,
45 )
47from colour.hints import List, cast
48from colour.utilities import (
49 as_array,
50 as_float_array,
51 as_int,
52 as_int_array,
53 as_int_scalar,
54 attest,
55 full,
56 is_iterable,
57 is_numeric,
58 multiline_repr,
59 multiline_str,
60 optional,
61 required,
62 runtime_warning,
63 tsplit,
64 tstack,
65 usage_warning,
66 validate_method,
67)
69__author__ = "Colour Developers"
70__copyright__ = "Copyright 2013 Colour Developers"
71__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
72__maintainer__ = "Colour Developers"
73__email__ = "colour-developers@colour-science.org"
74__status__ = "Production"
76__all__ = [
77 "AbstractLUT",
78 "LUT1D",
79 "LUT3x1D",
80 "LUT3D",
81 "LUT_to_LUT",
82]
85class AbstractLUT(ABC):
86 """
87 Define the base class for *LUT* (Look-Up Table).
89 This is an abstract base class (:class:`ABCMeta`) that must be inherited
90 by concrete *LUT* implementations to provide common functionality and
91 interface specifications.
93 Parameters
94 ----------
95 table
96 Underlying *LUT* table array containing the lookup values.
97 name
98 *LUT* identifying name.
99 dimensions
100 *LUT* dimensionality: typically 1 for a 1D *LUT*, 2 for a 3x1D *LUT*,
101 and 3 for a 3D *LUT*.
102 domain
103 *LUT* input domain boundaries, also used to define the instantiation
104 time default table domain.
105 size
106 *LUT* resolution or sampling density, also used to define the
107 instantiation time default table size.
108 comments
109 Additional comments or metadata to associate with the *LUT*.
111 Attributes
112 ----------
113 - :attr:`~colour.io.luts.lut.AbstractLUT.table`
114 - :attr:`~colour.io.luts.lut.AbstractLUT.name`
115 - :attr:`~colour.io.luts.lut.AbstractLUT.dimensions`
116 - :attr:`~colour.io.luts.lut.AbstractLUT.domain`
117 - :attr:`~colour.io.luts.lut.AbstractLUT.size`
118 - :attr:`~colour.io.luts.lut.AbstractLUT.comments`
120 Methods
121 -------
122 - :meth:`~colour.io.luts.lut.AbstractLUT.__init__`
123 - :meth:`~colour.io.luts.lut.AbstractLUT.__str__`
124 - :meth:`~colour.io.luts.lut.AbstractLUT.__repr__`
125 - :meth:`~colour.io.luts.lut.AbstractLUT.__eq__`
126 - :meth:`~colour.io.luts.lut.AbstractLUT.__ne__`
127 - :meth:`~colour.io.luts.lut.AbstractLUT.__add__`
128 - :meth:`~colour.io.luts.lut.AbstractLUT.__iadd__`
129 - :meth:`~colour.io.luts.lut.AbstractLUT.__sub__`
130 - :meth:`~colour.io.luts.lut.AbstractLUT.__isub__`
131 - :meth:`~colour.io.luts.lut.AbstractLUT.__mul__`
132 - :meth:`~colour.io.luts.lut.AbstractLUT.__imul__`
133 - :meth:`~colour.io.luts.lut.AbstractLUT.__div__`
134 - :meth:`~colour.io.luts.lut.AbstractLUT.__idiv__`
135 - :meth:`~colour.io.luts.lut.AbstractLUT.__pow__`
136 - :meth:`~colour.io.luts.lut.AbstractLUT.__ipow__`
137 - :meth:`~colour.io.luts.lut.AbstractLUT.arithmetical_operation`
138 - :meth:`~colour.io.luts.lut.AbstractLUT.is_domain_explicit`
139 - :meth:`~colour.io.luts.lut.AbstractLUT.linear_table`
140 - :meth:`~colour.io.luts.lut.AbstractLUT.copy`
141 - :meth:`~colour.io.luts.lut.AbstractLUT.invert`
142 - :meth:`~colour.io.luts.lut.AbstractLUT.apply`
143 - :meth:`~colour.io.luts.lut.AbstractLUT.convert`
144 """
146 def __init__(
147 self,
148 table: ArrayLike | None = None,
149 name: str | None = None,
150 dimensions: int | None = None,
151 domain: ArrayLike | None = None,
152 size: ArrayLike | None = None,
153 comments: Sequence | None = None,
154 ) -> None:
155 self._name: str = f"Unity {size!r}" if table is None else f"{id(self)}"
156 self.name = optional(name, self._name)
157 self._dimensions = optional(dimensions, 0)
158 self._table: NDArrayFloat = self.linear_table(
159 optional(size, 0), optional(domain, np.array([]))
160 )
161 self.table = optional(table, self._table)
162 self._domain: NDArrayFloat = np.array([])
163 self.domain = optional(domain, self._domain)
164 self._comments: list = []
165 self.comments = cast("list", optional(comments, self._comments))
167 @property
168 def table(self) -> NDArrayFloat:
169 """
170 Getter and setter for the underlying *LUT* table.
172 Access or modify the lookup table data structure that defines the
173 transformation mapping for this LUT instance.
175 Parameters
176 ----------
177 value
178 Value to set the underlying *LUT* table with.
180 Returns
181 -------
182 :class:`numpy.ndarray`
183 Underlying *LUT* table.
184 """
186 return self._table
188 @table.setter
189 def table(self, value: ArrayLike) -> None:
190 """Setter for the **self.table** property."""
192 self._table = self._validate_table(value)
194 @property
195 def name(self) -> str:
196 """
197 Getter and setter for the *LUT* name.
199 Parameters
200 ----------
201 value
202 Value to set the *LUT* name with.
204 Returns
205 -------
206 :class:`str`
207 *LUT* name.
208 """
210 return self._name
212 @name.setter
213 def name(self, value: str) -> None:
214 """Setter for the **self.name** property."""
216 attest(
217 isinstance(value, str),
218 f'"name" property: "{value}" type is not "str"!',
219 )
221 self._name = value
223 @property
224 def domain(self) -> NDArrayFloat:
225 """
226 Getter and setter for the *LUT* domain.
228 The domain defines the input coordinate space for the lookup table,
229 specifying the valid range of input values that can be interpolated.
231 Parameters
232 ----------
233 value
234 Value to set the *LUT* domain with.
236 Returns
237 -------
238 :class:`numpy.ndarray`
239 *LUT* domain.
240 """
242 return self._domain
244 @domain.setter
245 def domain(self, value: ArrayLike) -> None:
246 """Setter for the **self.domain** property."""
248 self._domain = self._validate_domain(value)
250 @property
251 def dimensions(self) -> int:
252 """
253 Getter for the *LUT* dimensions.
255 Returns
256 -------
257 :class:`int`
258 *LUT* dimensions.
259 """
261 return self._dimensions
263 @property
264 def size(self) -> int:
265 """
266 Getter for the *LUT* size.
268 Returns
269 -------
270 :class:`int`
271 *LUT* size.
272 """
274 return self._table.shape[0]
276 @property
277 def comments(self) -> list:
278 """
279 Getter and setter for the *LUT* comments.
281 Parameters
282 ----------
283 value
284 Value to set the *LUT* comments with.
286 Returns
287 -------
288 :class:`list`
289 *LUT* comments.
290 """
292 return self._comments
294 @comments.setter
295 def comments(self, value: Sequence) -> None:
296 """Setter for the **self.comments** property."""
298 attest(
299 is_iterable(value),
300 f'"comments" property: "{value}" must be a sequence!',
301 )
303 self._comments = list(value)
305 def __str__(self) -> str:
306 """
307 Return a formatted string representation of the *LUT*.
309 Returns
310 -------
311 :class:`str`
312 Formatted string representation.
313 """
315 attributes = [
316 {
317 "formatter": lambda x: ( # noqa: ARG005
318 f"{self.__class__.__name__} - {self.name}"
319 ),
320 "section": True,
321 },
322 {"line_break": True},
323 {"name": "dimensions", "label": "Dimensions"},
324 {"name": "domain", "label": "Domain"},
325 {
326 "label": "Size",
327 "formatter": lambda x: str(self.table.shape), # noqa: ARG005
328 },
329 ]
331 if self.comments:
332 attributes.append(
333 {
334 "formatter": lambda x: "\n".join( # noqa: ARG005
335 [
336 f"Comment {str(i + 1).zfill(2)} : {comment}"
337 for i, comment in enumerate(self.comments)
338 ]
339 ),
340 }
341 )
343 return multiline_str(self, cast("List[dict]", attributes))
345 def __repr__(self) -> str:
346 """
347 Return an evaluable string representation of the *LUT*.
349 This method provides a string that, when evaluated, recreates the
350 *LUT* object with its current state and configuration.
352 Returns
353 -------
354 :class:`str`
355 Evaluable string representation.
356 """
358 attributes = [
359 {"name": "table"},
360 {"name": "name"},
361 {"name": "domain"},
362 {"name": "size"},
363 ]
365 if self.comments:
366 attributes.append({"name": "comments"})
368 return multiline_repr(self, attributes)
370 __hash__ = None # pyright: ignore
372 def __eq__(self, other: object) -> bool:
373 """
374 Return whether the *LUT* is equal to the specified other object.
376 Parameters
377 ----------
378 other
379 Object to test whether it is equal to the *LUT*.
381 Returns
382 -------
383 :class:`bool`
384 Whether the specified object is equal to the *LUT*.
385 """
387 return isinstance(other, AbstractLUT) and all(
388 [
389 np.array_equal(self.table, other.table),
390 np.array_equal(self.domain, other.domain),
391 ]
392 )
394 def __ne__(self, other: object) -> bool:
395 """
396 Determine whether the *LUT* is not equal to the specified other
397 object.
399 Parameters
400 ----------
401 other
402 Object to test for inequality with the *LUT*.
404 Returns
405 -------
406 :class:`bool`
407 Whether the specified object is not equal to the *LUT*.
408 """
410 return not (self == other)
412 def __add__(self, a: ArrayLike | AbstractLUT) -> AbstractLUT:
413 """
414 Implement support for addition.
416 Parameters
417 ----------
418 a
419 *a* variable to add.
421 Returns
422 -------
423 :class:`colour.io.luts.lut.AbstractLUT`
424 Variable added *LUT*.
425 """
427 return self.arithmetical_operation(a, "+")
429 def __iadd__(self, a: ArrayLike | AbstractLUT) -> Self:
430 """
431 Implement support for in-place addition.
433 Add the specified operand to this *LUT* in-place, modifying the
434 current instance rather than creating a new one.
436 Parameters
437 ----------
438 a
439 Operand to add in-place. Can be a numeric array or another
440 *LUT* instance with compatible dimensions.
442 Returns
443 -------
444 :class:`colour.io.luts.lut.AbstractLUT`
445 Current *LUT* instance with the addition applied in-place.
446 """
448 return self.arithmetical_operation(a, "+", True)
450 def __sub__(self, a: ArrayLike | AbstractLUT) -> Self:
451 """
452 Implement support for subtraction.
454 Parameters
455 ----------
456 a
457 Variable, array or *LUT* to subtract from the current *LUT*.
459 Returns
460 -------
461 :class:`colour.io.luts.lut.AbstractLUT`
462 Variable subtracted *LUT*.
463 """
465 return self.arithmetical_operation(a, "-")
467 def __isub__(self, a: ArrayLike | AbstractLUT) -> Self:
468 """
469 Implement support for in-place subtraction.
471 Parameters
472 ----------
473 a
474 :math:`a` variable to subtract in-place.
476 Returns
477 -------
478 :class:`colour.io.luts.lut.AbstractLUT`
479 In-place variable subtracted *LUT*.
480 """
482 return self.arithmetical_operation(a, "-", True)
484 def __mul__(self, a: ArrayLike | AbstractLUT) -> Self:
485 """
486 Implement support for multiplication.
488 Parameters
489 ----------
490 a
491 Variable to multiply with the *LUT*. Can be a numeric array or
492 another *LUT* instance.
494 Returns
495 -------
496 :class:`colour.io.luts.lut.AbstractLUT`
497 Variable multiplied *LUT*.
498 """
500 return self.arithmetical_operation(a, "*")
502 def __imul__(self, a: ArrayLike | AbstractLUT) -> Self:
503 """
504 Implement support for in-place multiplication.
506 Parameters
507 ----------
508 a
509 :math:`a` variable to multiply by in-place.
511 Returns
512 -------
513 :class:`colour.io.luts.lut.AbstractLUT`
514 In-place variable multiplied *LUT*.
515 """
517 return self.arithmetical_operation(a, "*", True)
519 def __div__(self, a: ArrayLike | AbstractLUT) -> Self:
520 """
521 Implement support for division.
523 Parameters
524 ----------
525 a
526 :math:`a` variable to divide by.
528 Returns
529 -------
530 :class:`colour.io.luts.lut.AbstractLUT`
531 Variable divided *LUT*.
532 """
534 return self.arithmetical_operation(a, "/")
536 def __idiv__(self, a: ArrayLike | AbstractLUT) -> Self:
537 """
538 Perform in-place division of the *LUT* by the specified operand.
540 Parameters
541 ----------
542 a
543 Operand to divide the *LUT* by in-place.
545 Returns
546 -------
547 :class:`colour.io.luts.lut.AbstractLUT`
548 Current *LUT* instance with the division applied in-place.
549 """
551 return self.arithmetical_operation(a, "/", True)
553 __itruediv__ = __idiv__
554 __truediv__ = __div__
556 def __pow__(self, a: ArrayLike | AbstractLUT) -> Self:
557 """
558 Implement support for exponentiation.
560 Parameters
561 ----------
562 a
563 :math:`a` variable to exponentiate by.
565 Returns
566 -------
567 :class:`colour.io.luts.lut.AbstractLUT`
568 Variable exponentiated *LUT*.
569 """
571 return self.arithmetical_operation(a, "**")
573 def __ipow__(self, a: ArrayLike | AbstractLUT) -> Self:
574 """
575 Implement support for in-place exponentiation.
577 Parameters
578 ----------
579 a
580 :math:`a` variable to exponentiate by in-place.
582 Returns
583 -------
584 :class:`colour.io.luts.lut.AbstractLUT`
585 In-place variable exponentiated *LUT*.
586 """
588 return self.arithmetical_operation(a, "**", True)
590 def arithmetical_operation(
591 self,
592 a: ArrayLike | AbstractLUT,
593 operation: Literal["+", "-", "*", "/", "**"],
594 in_place: bool = False,
595 ) -> Self:
596 """
597 Perform the specified arithmetical operation with the :math:`a`
598 operand.
600 Execute the requested mathematical operation between this *LUT*
601 instance and the specified operand. The operation can be performed
602 either on a copy of the *LUT* or in-place on the current instance.
603 This method must be reimplemented by sub-classes to handle their
604 specific table structures.
606 Parameters
607 ----------
608 a
609 Operand for the arithmetical operation. Can be either a numeric
610 array or another *LUT* instance with compatible dimensions.
611 operation
612 Arithmetical operation to perform. Supported operations are
613 addition (``+``), subtraction (``-``), multiplication (``*``),
614 division (``/``), and exponentiation (``**``).
615 in_place
616 Whether to perform the operation in-place on the current *LUT*
617 instance (``True``) or on a copy (``False``).
619 Returns
620 -------
621 :class:`colour.io.luts.lut.AbstractLUT`
622 Modified *LUT* instance. If ``in_place`` is ``True``, returns
623 the current instance after modification. If ``False``, returns
624 a new modified copy.
625 """
627 operator, ioperator = {
628 "+": (add, iadd),
629 "-": (sub, isub),
630 "*": (mul, imul),
631 "/": (truediv, itruediv),
632 "**": (pow, ipow),
633 }[operation]
635 if in_place:
636 operand = a.table if isinstance(a, AbstractLUT) else as_float_array(a)
638 self.table = operator(self.table, operand)
640 return self
642 return ioperator(self.copy(), a)
644 @abstractmethod
645 def _validate_table(self, table: ArrayLike) -> NDArrayFloat:
646 """
647 Validate the specified table according to *LUT* dimensions.
649 Parameters
650 ----------
651 table
652 Table to validate.
654 Returns
655 -------
656 :class:`numpy.ndarray`
657 Validated table as a :class:`ndarray` instance.
658 """
660 @abstractmethod
661 def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat:
662 """
663 Validate specified domain according to *LUT* dimensions.
665 Parameters
666 ----------
667 domain
668 Domain to validate.
670 Returns
671 -------
672 :class:`numpy.ndarray`
673 Validated domain as a :class:`ndarray` instance.
674 """
676 @abstractmethod
677 def is_domain_explicit(self) -> bool:
678 """
679 Return whether the *LUT* domain is explicit (or implicit).
681 An implicit domain is defined by its shape only::
683 [[0 1]
684 [0 1]
685 [0 1]]
687 While an explicit domain defines every single discrete sample::
689 [[0.0 0.0 0.0]
690 [0.1 0.1 0.1]
691 [0.2 0.2 0.2]
692 [0.3 0.3 0.3]
693 [0.4 0.4 0.4]
694 [0.8 0.8 0.8]
695 [1.0 1.0 1.0]]
697 Returns
698 -------
699 :class:`bool`
700 Is *LUT* domain explicit.
701 """
703 @staticmethod
704 @abstractmethod
705 def linear_table(
706 size: ArrayLike | None = None,
707 domain: ArrayLike | None = None,
708 ) -> NDArrayFloat:
709 """
710 Generate a linear table of the specified size according to LUT dimensions.
712 Parameters
713 ----------
714 size
715 Expected table size, for a 1D *LUT*, the number of output samples
716 :math:`n` is equal to ``size``, for a 3x1D *LUT* :math:`n` is equal
717 to ``size * 3`` or ``size[0] + size[1] + size[2]``, for a 3D *LUT*
718 :math:`n` is equal to ``size**3 * 3`` or
719 ``size[0] * size[1] * size[2] * 3``.
720 domain
721 Domain of the table.
723 Returns
724 -------
725 :class:`numpy.ndarray`
726 Linear table.
727 """
729 def copy(self) -> AbstractLUT:
730 """
731 Return a copy of the sub-class instance.
733 Returns
734 -------
735 :class:`colour.io.luts.lut.AbstractLUT`
736 Copy of the LUT instance.
737 """
739 return deepcopy(self)
741 @abstractmethod
742 def invert(self, **kwargs: Any) -> AbstractLUT:
743 """
744 Compute and return an inverse copy of the *LUT*.
746 Other Parameters
747 ----------------
748 kwargs
749 Keywords arguments.
751 Returns
752 -------
753 :class:`colour.io.luts.lut.AbstractLUT`
754 Inverse *LUT* class instance.
755 """
757 @abstractmethod
758 def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat:
759 """
760 Apply the *LUT* to the specified *RGB* colourspace array using the
761 specified method.
763 Parameters
764 ----------
765 RGB
766 *RGB* colourspace array to apply the *LUT* onto.
768 Other Parameters
769 ----------------
770 direction
771 Whether the *LUT* should be applied in the forward or inverse
772 direction.
773 extrapolator
774 Extrapolator class type or object to use as extrapolating
775 function.
776 extrapolator_kwargs
777 Arguments to use when instantiating or calling the extrapolating
778 function.
779 interpolator
780 Interpolator class type or object to use as interpolating
781 function.
782 interpolator_kwargs
783 Arguments to use when instantiating or calling the interpolating
784 function.
786 Returns
787 -------
788 :class:`numpy.ndarray`
789 Interpolated *RGB* colourspace array.
790 """
792 def convert(
793 self,
794 cls: Type[AbstractLUT],
795 force_conversion: bool = False,
796 **kwargs: Any,
797 ) -> AbstractLUT:
798 """
799 Convert the *LUT* to the specified ``cls`` class instance.
801 Parameters
802 ----------
803 cls
804 *LUT* class instance.
805 force_conversion
806 Whether to force the conversion as it might be destructive.
808 Other Parameters
809 ----------------
810 interpolator
811 Interpolator class type to use as interpolating function.
812 interpolator_kwargs
813 Arguments to use when instantiating the interpolating function.
814 size
815 Expected table size in case of an upcast to or a downcast from a
816 :class:`LUT3D` class instance.
818 Returns
819 -------
820 :class:`colour.io.luts.lut.AbstractLUT`
821 Converted *LUT* class instance.
823 Warnings
824 --------
825 Some conversions are destructive and raise a :class:`ValueError`
826 exception by default.
828 Raises
829 ------
830 ValueError
831 If the conversion is destructive.
832 """
834 return LUT_to_LUT(self, cls, force_conversion, **kwargs)
837class LUT1D(AbstractLUT):
838 """
839 Define the base class for a 1D *LUT*.
841 A 1D (one-dimensional) lookup table provides a mapping function from
842 input values to output values through interpolation of discrete table
843 entries. This class is commonly used for tone mapping, gamma correction,
844 and other single-channel transformations where the output depends solely
845 on the input value.
847 Parameters
848 ----------
849 table
850 Underlying *LUT* table.
851 name
852 *LUT* name.
853 domain
854 *LUT* domain, also used to define the instantiation time default table
855 domain.
856 size
857 Size of the instantiation time default table, default to 10.
858 comments
859 Comments to add to the *LUT*.
861 Methods
862 -------
863 - :meth:`~colour.LUT1D.__init__`
864 - :meth:`~colour.LUT1D.is_domain_explicit`
865 - :meth:`~colour.LUT1D.linear_table`
866 - :meth:`~colour.LUT1D.invert`
867 - :meth:`~colour.LUT1D.apply`
869 Examples
870 --------
871 Instantiating a unity LUT with a table with 16 elements:
873 >>> print(LUT1D(size=16))
874 LUT1D - Unity 16
875 ----------------
876 <BLANKLINE>
877 Dimensions : 1
878 Domain : [ 0. 1.]
879 Size : (16,)
881 Instantiating a LUT using a custom table with 16 elements:
883 >>> print(LUT1D(LUT1D.linear_table(16) ** (1 / 2.2))) # doctest: +ELLIPSIS
884 LUT1D - ...
885 --------...
886 <BLANKLINE>
887 Dimensions : 1
888 Domain : [ 0. 1.]
889 Size : (16,)
891 Instantiating a LUT using a custom table with 16 elements, custom name,
892 custom domain and comments:
894 >>> from colour.algebra import spow
895 >>> domain = np.array([-0.1, 1.5])
896 >>> print(
897 ... LUT1D(
898 ... spow(LUT1D.linear_table(16, domain), 1 / 2.2),
899 ... "My LUT",
900 ... domain,
901 ... comments=["A first comment.", "A second comment."],
902 ... )
903 ... )
904 LUT1D - My LUT
905 --------------
906 <BLANKLINE>
907 Dimensions : 1
908 Domain : [-0.1 1.5]
909 Size : (16,)
910 Comment 01 : A first comment.
911 Comment 02 : A second comment.
912 """
914 def __init__(
915 self,
916 table: ArrayLike | None = None,
917 name: str | None = None,
918 domain: ArrayLike | None = None,
919 size: ArrayLike | None = None,
920 comments: Sequence | None = None,
921 ) -> None:
922 domain = as_float_array(optional(domain, np.array([0, 1])))
923 size = optional(size, 10)
925 super().__init__(table, name, 1, domain, size, comments)
927 def _validate_table(self, table: ArrayLike) -> NDArrayFloat:
928 """
929 Validate that the specified table is a 1D array.
931 Parameters
932 ----------
933 table
934 Table to validate.
936 Returns
937 -------
938 :class:`numpy.ndarray`
939 Validated table as a :class:`numpy.ndarray` instance.
940 """
942 table = as_float_array(table)
944 attest(len(table.shape) == 1, "The table must be a 1D array!")
946 return table
948 def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat:
949 """
950 Validate specified domain.
952 Parameters
953 ----------
954 domain
955 Domain to validate.
957 Returns
958 -------
959 :class:`numpy.ndarray`
960 Validated domain as a :class:`ndarray` instance.
961 """
963 domain = as_float_array(domain)
965 attest(len(domain.shape) == 1, "The domain must be a 1D array!")
967 attest(
968 domain.shape[0] >= 2,
969 "The domain column count must be equal or greater than 2!",
970 )
972 return domain
974 def is_domain_explicit(self) -> bool:
975 """
976 Return whether the *LUT* domain is explicit (or implicit).
978 An implicit domain is defined by its shape only::
980 [0 1]
982 While an explicit domain defines every single discrete samples::
984 [0.0 0.1 0.2 0.4 0.8 1.0]
986 Returns
987 -------
988 :class:`bool`
989 Is *LUT* domain explicit.
991 Examples
992 --------
993 >>> LUT1D().is_domain_explicit()
994 False
995 >>> table = domain = np.linspace(0, 1, 10)
996 >>> LUT1D(table, domain=domain).is_domain_explicit()
997 True
998 """
1000 return len(self.domain) != 2
1002 @staticmethod
1003 def linear_table(
1004 size: ArrayLike | None = None,
1005 domain: ArrayLike | None = None,
1006 ) -> NDArrayFloat:
1007 """
1008 Generate a linear table with the specified number of output samples
1009 :math:`n`.
1011 The table contains linearly spaced values across the specified domain.
1012 If no domain is provided, the default domain [0, 1] is used.
1014 Parameters
1015 ----------
1016 size
1017 Number of samples in the output table. Default is 10.
1018 domain
1019 Domain boundaries of the table as a 2-element array [min, max]
1020 or an array of values whose minimum and maximum define the
1021 domain. Default is [0, 1].
1023 Returns
1024 -------
1025 :class:`numpy.ndarray`
1026 Linear table containing ``size`` evenly spaced samples across
1027 the specified domain.
1029 Examples
1030 --------
1031 >>> LUT1D.linear_table(5, np.array([-0.1, 1.5]))
1032 array([-0.1, 0.3, 0.7, 1.1, 1.5])
1033 >>> LUT1D.linear_table(domain=np.linspace(-0.1, 1.5, 5))
1034 array([-0.1, 0.3, 0.7, 1.1, 1.5])
1035 """
1037 size = optional(size, 10)
1038 domain = as_float_array(optional(domain, np.array([0, 1])))
1040 if len(domain) != 2:
1041 return domain
1043 attest(is_numeric(size), "Linear table size must be a numeric!")
1045 return np.linspace(domain[0], domain[1], as_int_scalar(size))
1047 def invert(self, **kwargs: Any) -> LUT1D: # noqa: ARG002
1048 """
1049 Compute and return an inverse copy of the *LUT*.
1051 Other Parameters
1052 ----------------
1053 kwargs
1054 Keywords arguments, only specified for signature compatibility
1055 with the :meth:`AbstractLUT.invert` method.
1057 Returns
1058 -------
1059 :class:`colour.LUT1D`
1060 Inverse *LUT* class instance.
1062 Examples
1063 --------
1064 >>> LUT = LUT1D(LUT1D.linear_table() ** (1 / 2.2))
1065 >>> print(LUT.table) # doctest: +ELLIPSIS
1066 [ 0. ... 0.3683438... 0.5047603... 0.6069133... \
10670.6916988... 0.7655385...
1068 0.8316843... 0.8920493... 0.9478701... 1. ]
1069 >>> print(LUT.invert()) # doctest: +ELLIPSIS
1070 LUT1D - ... - Inverse
1071 --------...----------
1072 <BLANKLINE>
1073 Dimensions : 1
1074 Domain : [ 0. 0.3683438... 0.5047603... 0.6069133... \
10750.6916988... 0.7655385...
1076 0.8316843... 0.8920493... 0.9478701... 1. ]
1077 Size : (10,)
1078 >>> print(LUT.invert().table) # doctest: +ELLIPSIS
1079 [ 0. ... 0.1111111... 0.2222222... 0.3333333... \
10800.4444444... 0.5555555...
1081 0.6666666... 0.7777777... 0.8888888... 1. ]
1082 """
1084 if self.is_domain_explicit():
1085 domain = self.domain
1086 else:
1087 domain_min, domain_max = self.domain
1088 domain = np.linspace(domain_min, domain_max, self.size)
1090 return LUT1D(
1091 table=domain,
1092 name=f"{self.name} - Inverse",
1093 domain=self.table,
1094 )
1096 def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat:
1097 """
1098 Apply the *LUT* to the specified *RGB* colourspace array using the
1099 specified method.
1101 Parameters
1102 ----------
1103 RGB
1104 *RGB* colourspace array to apply the *LUT* onto.
1106 Other Parameters
1107 ----------------
1108 direction
1109 Whether the *LUT* should be applied in the forward or inverse
1110 direction.
1111 extrapolator
1112 Extrapolator class type or object to use as extrapolating
1113 function.
1114 extrapolator_kwargs
1115 Arguments to use when instantiating or calling the extrapolating
1116 function.
1117 interpolator
1118 Interpolator class type to use as interpolating function.
1119 interpolator_kwargs
1120 Arguments to use when instantiating the interpolating function.
1122 Returns
1123 -------
1124 :class:`numpy.ndarray`
1125 Interpolated *RGB* colourspace array.
1127 Examples
1128 --------
1129 >>> LUT = LUT1D(LUT1D.linear_table() ** (1 / 2.2))
1130 >>> RGB = np.array([0.18, 0.18, 0.18])
1132 *LUT* applied to the specified *RGB* colourspace in the forward
1133 direction:
1135 >>> LUT.apply(RGB) # doctest: +ELLIPSIS
1136 array([ 0.4529220..., 0.4529220..., 0.4529220...])
1138 *LUT* applied to the modified *RGB* colourspace in the inverse
1139 direction:
1141 >>> LUT.apply(LUT.apply(RGB), direction="Inverse")
1142 ... # doctest: +ELLIPSIS
1143 array([ 0.18..., 0.18..., 0.18...])
1144 """
1146 direction = validate_method(
1147 kwargs.get("direction", "Forward"), ("Forward", "Inverse")
1148 )
1150 interpolator = kwargs.get("interpolator", LinearInterpolator)
1151 interpolator_kwargs = kwargs.get("interpolator_kwargs", {})
1152 extrapolator = kwargs.get("extrapolator", Extrapolator)
1153 extrapolator_kwargs = kwargs.get("extrapolator_kwargs", {})
1155 LUT = self.invert() if direction == "inverse" else self
1157 if LUT.is_domain_explicit():
1158 samples = LUT.domain
1159 else:
1160 domain_min, domain_max = LUT.domain
1161 samples = np.linspace(domain_min, domain_max, LUT.size)
1163 RGB_interpolator = extrapolator(
1164 interpolator(samples, LUT.table, **interpolator_kwargs),
1165 **extrapolator_kwargs,
1166 )
1168 return RGB_interpolator(RGB)
1171class LUT3x1D(AbstractLUT):
1172 """
1173 Define the base class for a 3x1D *LUT*.
1175 A 3x1D (three-by-one-dimensional) lookup table applies independent
1176 transformations to each channel of a three-channel input. Each channel
1177 has its own 1D lookup table, enabling per-channel colour corrections
1178 and tone mapping operations.
1180 Parameters
1181 ----------
1182 table
1183 Underlying *LUT* table.
1184 name
1185 *LUT* name.
1186 domain
1187 *LUT* domain, also used to define the instantiation time default
1188 table domain.
1189 size
1190 Size of the instantiation time default table, default to 10.
1191 comments
1192 Comments to add to the *LUT*.
1194 Methods
1195 -------
1196 - :meth:`~colour.LUT3x1D.__init__`
1197 - :meth:`~colour.LUT3x1D.is_domain_explicit`
1198 - :meth:`~colour.LUT3x1D.linear_table`
1199 - :meth:`~colour.LUT3x1D.invert`
1200 - :meth:`~colour.LUT3x1D.apply`
1202 Examples
1203 --------
1204 Instantiating a unity LUT with a table with 16x3 elements:
1206 >>> print(LUT3x1D(size=16))
1207 LUT3x1D - Unity 16
1208 ------------------
1209 <BLANKLINE>
1210 Dimensions : 2
1211 Domain : [[ 0. 0. 0.]
1212 [ 1. 1. 1.]]
1213 Size : (16, 3)
1215 Instantiating a LUT using a custom table with 16x3 elements:
1217 >>> print(LUT3x1D(LUT3x1D.linear_table(16) ** (1 / 2.2)))
1218 ... # doctest: +ELLIPSIS
1219 LUT3x1D - ...
1220 ----------...
1221 <BLANKLINE>
1222 Dimensions : 2
1223 Domain : [[ 0. 0. 0.]
1224 [ 1. 1. 1.]]
1225 Size : (16, 3)
1227 Instantiating a LUT using a custom table with 16x3 elements, custom
1228 name, custom domain and comments:
1230 >>> from colour.algebra import spow
1231 >>> domain = np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]])
1232 >>> print(
1233 ... LUT3x1D(
1234 ... spow(LUT3x1D.linear_table(16), 1 / 2.2),
1235 ... "My LUT",
1236 ... domain,
1237 ... comments=["A first comment.", "A second comment."],
1238 ... )
1239 ... )
1240 LUT3x1D - My LUT
1241 ----------------
1242 <BLANKLINE>
1243 Dimensions : 2
1244 Domain : [[-0.1 -0.2 -0.4]
1245 [ 1.5 3. 6. ]]
1246 Size : (16, 3)
1247 Comment 01 : A first comment.
1248 Comment 02 : A second comment.
1249 """
1251 def __init__(
1252 self,
1253 table: ArrayLike | None = None,
1254 name: str | None = None,
1255 domain: ArrayLike | None = None,
1256 size: ArrayLike | None = None,
1257 comments: Sequence | None = None,
1258 ) -> None:
1259 domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]]))
1260 size = optional(size, 10)
1262 super().__init__(table, name, 2, domain, size, comments)
1264 def _validate_table(self, table: ArrayLike) -> NDArrayFloat:
1265 """
1266 Validate specified table is a 3x1D array.
1268 Parameters
1269 ----------
1270 table
1271 Table to validate.
1273 Returns
1274 -------
1275 :class:`numpy.ndarray`
1276 Validated table as a :class:`ndarray` instance.
1277 """
1279 table = as_float_array(table)
1281 attest(len(table.shape) == 2, "The table must be a 2D array!")
1283 return table
1285 def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat:
1286 """
1287 Validate the specified domain for the lookup table.
1289 Parameters
1290 ----------
1291 domain
1292 Domain to validate.
1294 Returns
1295 -------
1296 :class:`numpy.ndarray`
1297 Validated domain as a :class:`ndarray` instance.
1298 """
1300 domain = as_float_array(domain)
1302 attest(len(domain.shape) == 2, "The domain must be a 2D array!")
1304 attest(
1305 domain.shape[0] >= 2,
1306 "The domain row count must be equal or greater than 2!",
1307 )
1309 attest(domain.shape[1] == 3, "The domain column count must be equal to 3!")
1311 return domain
1313 def is_domain_explicit(self) -> bool:
1314 """
1315 Return whether the *LUT* domain is explicit (or implicit).
1317 An implicit domain is defined by its shape only::
1319 [[0 1]
1320 [0 1]
1321 [0 1]]
1323 While an explicit domain defines every single discrete samples::
1325 [[0.0 0.0 0.0]
1326 [0.1 0.1 0.1]
1327 [0.2 0.2 0.2]
1328 [0.3 0.3 0.3]
1329 [0.4 0.4 0.4]
1330 [0.8 0.8 0.8]
1331 [1.0 1.0 1.0]]
1333 Returns
1334 -------
1335 :class:`bool`
1336 Is *LUT* domain explicit.
1338 Examples
1339 --------
1340 >>> LUT3x1D().is_domain_explicit()
1341 False
1342 >>> samples = np.linspace(0, 1, 10)
1343 >>> table = domain = tstack([samples, samples, samples])
1344 >>> LUT3x1D(table, domain=domain).is_domain_explicit()
1345 True
1346 """
1348 return self.domain.shape != (2, 3)
1350 @staticmethod
1351 def linear_table(
1352 size: ArrayLike | None = None,
1353 domain: ArrayLike | None = None,
1354 ) -> NDArrayFloat:
1355 """
1356 Generate a linear table with the specified size and domain.
1358 The number of output samples :math:`n` is equal to ``size * 3`` or
1359 ``size[0] + size[1] + size[2]``.
1361 Parameters
1362 ----------
1363 size
1364 Expected table size, default to 10.
1365 domain
1366 Domain of the table.
1368 Returns
1369 -------
1370 :class:`numpy.ndarray`
1371 Linear table with ``size * 3`` or ``size[0] + size[1] +
1372 size[2]`` samples.
1374 Warnings
1375 --------
1376 If ``size`` is non uniform, the linear table will be padded
1377 accordingly.
1379 Examples
1380 --------
1381 >>> LUT3x1D.linear_table(5, np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]))
1382 array([[-0.1, -0.2, -0.4],
1383 [ 0.3, 0.6, 1.2],
1384 [ 0.7, 1.4, 2.8],
1385 [ 1.1, 2.2, 4.4],
1386 [ 1.5, 3. , 6. ]])
1387 >>> LUT3x1D.linear_table(
1388 ... np.array([5, 3, 2]),
1389 ... np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]),
1390 ... )
1391 array([[-0.1, -0.2, -0.4],
1392 [ 0.3, 1.4, 6. ],
1393 [ 0.7, 3. , nan],
1394 [ 1.1, nan, nan],
1395 [ 1.5, nan, nan]])
1396 >>> domain = np.array(
1397 ... [
1398 ... [-0.1, -0.2, -0.4],
1399 ... [0.3, 1.4, 6.0],
1400 ... [0.7, 3.0, np.nan],
1401 ... [1.1, np.nan, np.nan],
1402 ... [1.5, np.nan, np.nan],
1403 ... ]
1404 ... )
1405 >>> LUT3x1D.linear_table(domain=domain)
1406 array([[-0.1, -0.2, -0.4],
1407 [ 0.3, 1.4, 6. ],
1408 [ 0.7, 3. , nan],
1409 [ 1.1, nan, nan],
1410 [ 1.5, nan, nan]])
1411 """
1413 size = optional(size, 10)
1414 domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]]))
1416 if domain.shape != (2, 3):
1417 return domain
1419 size_array = np.tile(size, 3) if is_numeric(size) else as_int_array(size)
1421 R, G, B = tsplit(domain)
1423 samples = [
1424 np.linspace(a[0], a[1], size_array[i]) for i, a in enumerate([R, G, B])
1425 ]
1427 if len(np.unique(size_array)) != 1:
1428 runtime_warning(
1429 'Table is non uniform, axis will be padded with "NaNs" accordingly!'
1430 )
1432 samples = [
1433 np.pad(
1434 axis,
1435 (0, np.max(size_array) - len(axis)), # pyright: ignore
1436 mode="constant",
1437 constant_values=np.nan,
1438 )
1439 for axis in samples
1440 ]
1442 return tstack(samples)
1444 def invert(self, **kwargs: Any) -> LUT3x1D: # noqa: ARG002
1445 """
1446 Compute and return an inverse copy of the *LUT*.
1448 Other Parameters
1449 ----------------
1450 kwargs
1451 Keywords arguments, only specified for signature compatibility with
1452 the :meth:`AbstractLUT.invert` method.
1454 Returns
1455 -------
1456 :class:`colour.LUT3x1D`
1457 Inverse *LUT* class instance.
1459 Examples
1460 --------
1461 >>> LUT = LUT3x1D(LUT3x1D.linear_table() ** (1 / 2.2))
1462 >>> print(LUT.table)
1463 [[ 0. 0. 0. ]
1464 [ 0.36834383 0.36834383 0.36834383]
1465 [ 0.50476034 0.50476034 0.50476034]
1466 [ 0.60691337 0.60691337 0.60691337]
1467 [ 0.69169882 0.69169882 0.69169882]
1468 [ 0.76553851 0.76553851 0.76553851]
1469 [ 0.83168433 0.83168433 0.83168433]
1470 [ 0.89204934 0.89204934 0.89204934]
1471 [ 0.94787016 0.94787016 0.94787016]
1472 [ 1. 1. 1. ]]
1473 >>> print(LUT.invert()) # doctest: +ELLIPSIS
1474 LUT3x1D - ... - Inverse
1475 ----------...----------
1476 <BLANKLINE>
1477 Dimensions : 2
1478 Domain : [[ 0. ... 0. ... 0. ...]
1479 [ 0.3683438... 0.3683438... 0.3683438...]
1480 [ 0.5047603... 0.5047603... 0.5047603...]
1481 [ 0.6069133... 0.6069133... 0.6069133...]
1482 [ 0.6916988... 0.6916988... 0.6916988...]
1483 [ 0.7655385... 0.7655385... 0.7655385...]
1484 [ 0.8316843... 0.8316843... 0.8316843...]
1485 [ 0.8920493... 0.8920493... 0.8920493...]
1486 [ 0.9478701... 0.9478701... 0.9478701...]
1487 [ 1. ... 1. ... 1. ...]]
1488 Size : (10, 3)
1489 >>> print(LUT.invert().table) # doctest: +ELLIPSIS
1490 [[ 0. ... 0. ... 0. ...]
1491 [ 0.1111111... 0.1111111... 0.1111111...]
1492 [ 0.2222222... 0.2222222... 0.2222222...]
1493 [ 0.3333333... 0.3333333... 0.3333333...]
1494 [ 0.4444444... 0.4444444... 0.4444444...]
1495 [ 0.5555555... 0.5555555... 0.5555555...]
1496 [ 0.6666666... 0.6666666... 0.6666666...]
1497 [ 0.7777777... 0.7777777... 0.7777777...]
1498 [ 0.8888888... 0.8888888... 0.8888888...]
1499 [ 1. ... 1. ... 1. ...]]
1500 """
1502 size = self.table.size // 3
1503 if self.is_domain_explicit():
1504 domain = [
1505 axes[: (~np.isnan(axes)).cumsum().argmax() + 1]
1506 for axes in np.transpose(self.domain)
1507 ]
1508 else:
1509 domain_min, domain_max = self.domain
1510 domain = [np.linspace(domain_min[i], domain_max[i], size) for i in range(3)]
1512 return LUT3x1D(
1513 table=tstack(domain),
1514 name=f"{self.name} - Inverse",
1515 domain=self.table,
1516 )
1518 def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat:
1519 """
1520 Apply the *LUT* to the specified *RGB* colourspace array using the
1521 specified method.
1523 Parameters
1524 ----------
1525 RGB
1526 *RGB* colourspace array to apply the *LUT* onto.
1528 Other Parameters
1529 ----------------
1530 direction
1531 Whether the *LUT* should be applied in the forward or inverse
1532 direction.
1533 extrapolator
1534 Extrapolator class type or object to use as extrapolating
1535 function.
1536 extrapolator_kwargs
1537 Arguments to use when instantiating or calling the extrapolating
1538 function.
1539 interpolator
1540 Interpolator class type to use as interpolating function.
1541 interpolator_kwargs
1542 Arguments to use when instantiating the interpolating function.
1544 Returns
1545 -------
1546 :class:`numpy.ndarray`
1547 Interpolated *RGB* colourspace array.
1549 Examples
1550 --------
1551 >>> LUT = LUT3x1D(LUT3x1D.linear_table() ** (1 / 2.2))
1552 >>> RGB = np.array([0.18, 0.18, 0.18])
1553 >>> LUT.apply(RGB) # doctest: +ELLIPSIS
1554 array([ 0.4529220..., 0.4529220..., 0.4529220...])
1555 >>> LUT.apply(LUT.apply(RGB), direction="Inverse")
1556 ... # doctest: +ELLIPSIS
1557 array([ 0.18..., 0.18..., 0.18...])
1558 >>> from colour.algebra import spow
1559 >>> domain = np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]])
1560 >>> table = spow(LUT3x1D.linear_table(domain=domain), 1 / 2.2)
1561 >>> LUT = LUT3x1D(table, domain=domain)
1562 >>> RGB = np.array([0.18, 0.18, 0.18])
1563 >>> LUT.apply(RGB) # doctest: +ELLIPSIS
1564 array([ 0.4423903..., 0.4503801..., 0.3581625...])
1565 >>> domain = np.array(
1566 ... [
1567 ... [-0.1, -0.2, -0.4],
1568 ... [0.3, 1.4, 6.0],
1569 ... [0.7, 3.0, np.nan],
1570 ... [1.1, np.nan, np.nan],
1571 ... [1.5, np.nan, np.nan],
1572 ... ]
1573 ... )
1574 >>> table = spow(LUT3x1D.linear_table(domain=domain), 1 / 2.2)
1575 >>> LUT = LUT3x1D(table, domain=domain)
1576 >>> RGB = np.array([0.18, 0.18, 0.18])
1577 >>> LUT.apply(RGB) # doctest: +ELLIPSIS
1578 array([ 0.2996370..., -0.0901332..., -0.3949770...])
1579 """
1581 direction = validate_method(
1582 kwargs.get("direction", "Forward"), ("Forward", "Inverse")
1583 )
1585 interpolator = kwargs.get("interpolator", LinearInterpolator)
1586 interpolator_kwargs = kwargs.get("interpolator_kwargs", {})
1587 extrapolator = kwargs.get("extrapolator", Extrapolator)
1588 extrapolator_kwargs = kwargs.get("extrapolator_kwargs", {})
1590 R, G, B = tsplit(RGB)
1592 LUT = self.invert() if direction == "inverse" else self
1594 size = LUT.table.size // 3
1595 if LUT.is_domain_explicit():
1596 samples = [
1597 axes[: (~np.isnan(axes)).cumsum().argmax() + 1]
1598 for axes in np.transpose(LUT.domain)
1599 ]
1600 R_t, G_t, B_t = (
1601 axes[: len(samples[i])]
1602 for i, axes in enumerate(np.transpose(LUT.table))
1603 )
1604 else:
1605 domain_min, domain_max = LUT.domain
1606 samples = [
1607 np.linspace(domain_min[i], domain_max[i], size) for i in range(3)
1608 ]
1609 R_t, G_t, B_t = tsplit(LUT.table)
1611 s_R, s_G, s_B = samples
1613 RGB_i = [
1614 extrapolator(
1615 interpolator(a[0], a[1], **interpolator_kwargs),
1616 **extrapolator_kwargs,
1617 )(a[2])
1618 for a in zip((s_R, s_G, s_B), (R_t, G_t, B_t), (R, G, B), strict=True)
1619 ]
1621 return tstack(RGB_i)
1624class LUT3D(AbstractLUT):
1625 """
1626 Define the base class for a 3-dimensional lookup table (3D *LUT*).
1628 This class provides a foundation for working with 3D lookup tables,
1629 which map input colour values through a discretized 3D grid to output
1630 colour values. The table operates on three input channels
1631 simultaneously, making it suitable for RGB-to-RGB colour
1632 transformations and other tristimulus colour space operations.
1634 Parameters
1635 ----------
1636 table
1637 Underlying *LUT* table.
1638 name
1639 *LUT* name.
1640 domain
1641 *LUT* domain, also used to define the instantiation time default
1642 table domain.
1643 size
1644 Size of the instantiation time default table, default to 33.
1645 comments
1646 Comments to add to the *LUT*.
1648 Methods
1649 -------
1650 - :meth:`~colour.LUT3D.__init__`
1651 - :meth:`~colour.LUT3D.is_domain_explicit`
1652 - :meth:`~colour.LUT3D.linear_table`
1653 - :meth:`~colour.LUT3D.invert`
1654 - :meth:`~colour.LUT3D.apply`
1656 Examples
1657 --------
1658 Instantiating a unity LUT with a table with 16x16x16x3 elements:
1660 >>> print(LUT3D(size=16))
1661 LUT3D - Unity 16
1662 ----------------
1663 <BLANKLINE>
1664 Dimensions : 3
1665 Domain : [[ 0. 0. 0.]
1666 [ 1. 1. 1.]]
1667 Size : (16, 16, 16, 3)
1669 Instantiating a LUT using a custom table with 16x16x16x3 elements:
1671 >>> print(LUT3D(LUT3D.linear_table(16) ** (1 / 2.2))) # doctest: +ELLIPSIS
1672 LUT3D - ...
1673 --------...
1674 <BLANKLINE>
1675 Dimensions : 3
1676 Domain : [[ 0. 0. 0.]
1677 [ 1. 1. 1.]]
1678 Size : (16, 16, 16, 3)
1680 Instantiating a LUT using a custom table with 16x16x16x3 elements,
1681 custom name, custom domain and comments:
1683 >>> from colour.algebra import spow
1684 >>> domain = np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]])
1685 >>> print(
1686 ... LUT3D(
1687 ... spow(LUT3D.linear_table(16), 1 / 2.2),
1688 ... "My LUT",
1689 ... domain,
1690 ... comments=["A first comment.", "A second comment."],
1691 ... )
1692 ... )
1693 LUT3D - My LUT
1694 --------------
1695 <BLANKLINE>
1696 Dimensions : 3
1697 Domain : [[-0.1 -0.2 -0.4]
1698 [ 1.5 3. 6. ]]
1699 Size : (16, 16, 16, 3)
1700 Comment 01 : A first comment.
1701 Comment 02 : A second comment.
1702 """
1704 def __init__(
1705 self,
1706 table: ArrayLike | None = None,
1707 name: str | None = None,
1708 domain: ArrayLike | None = None,
1709 size: ArrayLike | None = None,
1710 comments: Sequence | None = None,
1711 ) -> None:
1712 domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]]))
1713 size = optional(size, 33)
1715 super().__init__(table, name, 3, domain, size, comments)
1717 def _validate_table(self, table: ArrayLike) -> NDArrayFloat:
1718 """
1719 Validate that the specified table is a 4D array with equal
1720 dimensions.
1722 Parameters
1723 ----------
1724 table
1725 Table to validate.
1727 Returns
1728 -------
1729 :class:`numpy.ndarray`
1730 Validated table as a :class:`numpy.ndarray` instance.
1731 """
1733 table = as_float_array(table)
1735 attest(len(table.shape) == 4, "The table must be a 4D array!")
1737 return table
1739 def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat:
1740 """
1741 Validate the specified domain for the 3D lookup table.
1743 Parameters
1744 ----------
1745 domain
1746 Domain array to validate. Must be a 2D array with at least 2
1747 rows and exactly 3 columns.
1749 Returns
1750 -------
1751 :class:`numpy.ndarray`
1752 Validated domain as a :class:`numpy.ndarray` instance.
1754 Notes
1755 -----
1756 - A :class:`LUT3D` class instance must use an implicit domain.
1757 """
1759 domain = as_float_array(domain)
1761 attest(len(domain.shape) == 2, "The domain must be a 2D array!")
1763 attest(
1764 domain.shape[0] >= 2,
1765 "The domain row count must be equal or greater than 2!",
1766 )
1768 attest(domain.shape[1] == 3, "The domain column count must be equal to 3!")
1770 return domain
1772 def is_domain_explicit(self) -> bool:
1773 """
1774 Return whether the *LUT* domain is explicit (or implicit).
1776 An implicit domain is defined by its shape only::
1778 [[0 0 0]
1779 [1 1 1]]
1781 While an explicit domain defines every single discrete sample::
1783 [[0.0 0.0 0.0]
1784 [0.1 0.1 0.1]
1785 [0.2 0.2 0.2]
1786 [0.3 0.3 0.3]
1787 [0.4 0.4 0.4]
1788 [0.8 0.8 0.8]
1789 [1.0 1.0 1.0]]
1791 Returns
1792 -------
1793 :class:`bool`
1794 Is *LUT* domain explicit.
1796 Examples
1797 --------
1798 >>> LUT3D().is_domain_explicit()
1799 False
1800 >>> domain = np.array([[-0.1, -0.2, -0.4], [0.7, 1.4, 6.0], [1.5, 3.0, np.nan]])
1801 >>> LUT3D(domain=domain).is_domain_explicit()
1802 True
1803 """
1805 return self.domain.shape != (2, 3)
1807 @staticmethod
1808 def linear_table(
1809 size: ArrayLike | None = None,
1810 domain: ArrayLike | None = None,
1811 ) -> NDArrayFloat:
1812 """
1813 Generate a linear table with the specified size and domain.
1815 The number of output samples :math:`n` is equal to ``size**3 * 3`` or
1816 ``size[0] * size[1] * size[2] * 3``.
1818 Parameters
1819 ----------
1820 size
1821 Expected table size, default to 33.
1822 domain
1823 Domain of the table.
1825 Returns
1826 -------
1827 :class:`numpy.ndarray`
1828 Linear table with ``size**3 * 3`` or
1829 ``size[0] * size[1] * size[2] * 3`` samples.
1831 Examples
1832 --------
1833 >>> LUT3D.linear_table(3, np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]))
1834 array([[[[-0.1, -0.2, -0.4],
1835 [-0.1, -0.2, 2.8],
1836 [-0.1, -0.2, 6. ]],
1837 <BLANKLINE>
1838 [[-0.1, 1.4, -0.4],
1839 [-0.1, 1.4, 2.8],
1840 [-0.1, 1.4, 6. ]],
1841 <BLANKLINE>
1842 [[-0.1, 3. , -0.4],
1843 [-0.1, 3. , 2.8],
1844 [-0.1, 3. , 6. ]]],
1845 <BLANKLINE>
1846 <BLANKLINE>
1847 [[[ 0.7, -0.2, -0.4],
1848 [ 0.7, -0.2, 2.8],
1849 [ 0.7, -0.2, 6. ]],
1850 <BLANKLINE>
1851 [[ 0.7, 1.4, -0.4],
1852 [ 0.7, 1.4, 2.8],
1853 [ 0.7, 1.4, 6. ]],
1854 <BLANKLINE>
1855 [[ 0.7, 3. , -0.4],
1856 [ 0.7, 3. , 2.8],
1857 [ 0.7, 3. , 6. ]]],
1858 <BLANKLINE>
1859 <BLANKLINE>
1860 [[[ 1.5, -0.2, -0.4],
1861 [ 1.5, -0.2, 2.8],
1862 [ 1.5, -0.2, 6. ]],
1863 <BLANKLINE>
1864 [[ 1.5, 1.4, -0.4],
1865 [ 1.5, 1.4, 2.8],
1866 [ 1.5, 1.4, 6. ]],
1867 <BLANKLINE>
1868 [[ 1.5, 3. , -0.4],
1869 [ 1.5, 3. , 2.8],
1870 [ 1.5, 3. , 6. ]]]])
1871 >>> LUT3D.linear_table(
1872 ... np.array([3, 3, 2]),
1873 ... np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]),
1874 ... )
1875 array([[[[-0.1, -0.2, -0.4],
1876 [-0.1, -0.2, 6. ]],
1877 <BLANKLINE>
1878 [[-0.1, 1.4, -0.4],
1879 [-0.1, 1.4, 6. ]],
1880 <BLANKLINE>
1881 [[-0.1, 3. , -0.4],
1882 [-0.1, 3. , 6. ]]],
1883 <BLANKLINE>
1884 <BLANKLINE>
1885 [[[ 0.7, -0.2, -0.4],
1886 [ 0.7, -0.2, 6. ]],
1887 <BLANKLINE>
1888 [[ 0.7, 1.4, -0.4],
1889 [ 0.7, 1.4, 6. ]],
1890 <BLANKLINE>
1891 [[ 0.7, 3. , -0.4],
1892 [ 0.7, 3. , 6. ]]],
1893 <BLANKLINE>
1894 <BLANKLINE>
1895 [[[ 1.5, -0.2, -0.4],
1896 [ 1.5, -0.2, 6. ]],
1897 <BLANKLINE>
1898 [[ 1.5, 1.4, -0.4],
1899 [ 1.5, 1.4, 6. ]],
1900 <BLANKLINE>
1901 [[ 1.5, 3. , -0.4],
1902 [ 1.5, 3. , 6. ]]]])
1903 >>> domain = np.array([[-0.1, -0.2, -0.4], [0.7, 1.4, 6.0], [1.5, 3.0, np.nan]])
1904 >>> LUT3D.linear_table(domain=domain)
1905 array([[[[-0.1, -0.2, -0.4],
1906 [-0.1, -0.2, 6. ]],
1907 <BLANKLINE>
1908 [[-0.1, 1.4, -0.4],
1909 [-0.1, 1.4, 6. ]],
1910 <BLANKLINE>
1911 [[-0.1, 3. , -0.4],
1912 [-0.1, 3. , 6. ]]],
1913 <BLANKLINE>
1914 <BLANKLINE>
1915 [[[ 0.7, -0.2, -0.4],
1916 [ 0.7, -0.2, 6. ]],
1917 <BLANKLINE>
1918 [[ 0.7, 1.4, -0.4],
1919 [ 0.7, 1.4, 6. ]],
1920 <BLANKLINE>
1921 [[ 0.7, 3. , -0.4],
1922 [ 0.7, 3. , 6. ]]],
1923 <BLANKLINE>
1924 <BLANKLINE>
1925 [[[ 1.5, -0.2, -0.4],
1926 [ 1.5, -0.2, 6. ]],
1927 <BLANKLINE>
1928 [[ 1.5, 1.4, -0.4],
1929 [ 1.5, 1.4, 6. ]],
1930 <BLANKLINE>
1931 [[ 1.5, 3. , -0.4],
1932 [ 1.5, 3. , 6. ]]]])
1933 """
1935 size = optional(size, 33)
1936 domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]]))
1938 if domain.shape != (2, 3):
1939 samples = list(
1940 np.flip(
1941 # NOTE: "dtype=object" is required for ragged array support
1942 # in "Numpy" 1.24.0.
1943 as_array(
1944 [
1945 axes[: (~np.isnan(axes)).cumsum().argmax() + 1]
1946 for axes in np.transpose(domain)
1947 ],
1948 dtype=object, # pyright: ignore
1949 ),
1950 -1,
1951 )
1952 )
1953 size_array = as_int_array([len(axes) for axes in samples])
1954 else:
1955 size_array = np.tile(size, 3) if is_numeric(size) else as_int_array(size)
1957 R, G, B = tsplit(domain)
1959 size_array = np.flip(size_array, -1)
1960 samples = [
1961 np.linspace(a[0], a[1], size_array[i]) for i, a in enumerate([B, G, R])
1962 ]
1964 return np.flip(
1965 np.reshape(
1966 np.transpose(np.meshgrid(*samples, indexing="ij")),
1967 np.hstack([np.flip(size_array, -1), 3]),
1968 ),
1969 -1,
1970 )
1972 @required("SciPy")
1973 def invert(self, **kwargs: Any) -> LUT3D:
1974 """
1975 Compute and return an inverse copy of the *LUT*.
1977 Other Parameters
1978 ----------------
1979 interpolator
1980 Interpolator class type or object to use as interpolating
1981 function.
1982 query_size
1983 Number of nearest neighbors to use for Shepard interpolation
1984 (inverse distance weighting). Default is 8, optimized for speed and
1985 quality. Higher values (16-32) may slightly improve smoothness but
1986 significantly increase computation time.
1987 gamma
1988 Gradient smoothness parameter for Shepard interpolation. Default is
1989 3.0 (optimized for smoothness). Controls the weight falloff rate in
1990 inverse distance weighting (:math:`w_i = 1/d_i^{1/gamma}`). Higher
1991 gamma values produce smoother gradients.
1993 - Default (3.0): Optimal smoothness with minimal artifacts
1994 - Lower values (1.5-2.0): Sharper transitions, faster computation,
1995 may increase banding artifacts
1996 - Very low values (0.5-1.0): Maximum sharpness, more localized
1997 interpolation, higher banding risk
1998 sigma
1999 Gaussian blur sigma for iterative adaptive smoothing.
2000 Default is 0.7. Smoothing is applied iteratively only to
2001 high-gradient regions (banding artifacts) identified using the
2002 percentile threshold, preserving quality in smooth regions.
2004 - Default (0.7): Optimal smoothing - reduces banding by ~38%
2005 (26 → 16 artifacts) while preserving corners
2006 - Higher values (0.8-0.9): More aggressive, may increase corner shift
2007 - Lower values (0.5-0.6): Gentler smoothing, better corner preservation
2008 - Set to 0.0 to disable adaptive smoothing entirely
2010 The iterative adaptive approach with gradient recomputation ensures
2011 clean LUTs remain unaffected while problematic regions receive
2012 targeted smoothing.
2013 tau
2014 Percentile threshold for identifying high-gradient regions (0-1).
2015 Default is 0.75 (75th percentile). Higher values mean fewer regions
2016 are smoothed (more selective), lower values mean more regions are
2017 smoothed (more aggressive).
2019 - Default (0.75): Smooths top 25% of gradient regions
2020 - Higher values (0.85-0.95): Very selective, minimal smoothing
2021 - Lower values (0.50-0.65): More aggressive, smooths more regions
2023 Only used when sigma > 0.
2024 iterations
2025 Number of iterative smoothing passes. Default is 10.
2026 Each iteration recomputes gradients and adapts smoothing to the
2027 evolving LUT state, providing better artifact reduction than a
2028 single strong blur.
2030 - Default (10): Optimal balance of quality and performance
2031 - Higher values (12-15): Slightly better artifact reduction, slower
2032 - Lower values (5-7): Faster, but fewer artifacts removed
2034 Only used when sigma > 0.
2035 oversampling
2036 Oversampling factor for building the KDTree. Default is 1.2.
2037 The optimal value is based on Jacobian analysis of the LUT
2038 transformation: the Jacobian matrix
2039 :math:`J = \\partial(output)/\\partial(input)` measures local
2040 volume distortion. When :math:`|J| < 1`, the LUT compresses space,
2041 requiring higher sampling density for accurate inversion.
2042 The factor 1.2 captures approximately 80% of the theoretical
2043 accuracy benefit at 30% of the computational cost. Values between
2044 1.0 (no oversampling) and 2.0 (diminishing returns) are supported.
2045 size
2046 Size of the inverse *LUT*. With the specified implementation,
2047 it is good practise to double the size of the inverse *LUT* to
2048 provide a smoother result. If ``size`` is not specified,
2049 :math:`2^{\\sqrt{size_{LUT}} + 1} + 1` will be used instead.
2051 Returns
2052 -------
2053 :class:`colour.LUT3D`
2054 Inverse *LUT* class instance.
2056 Examples
2057 --------
2058 >>> LUT = LUT3D()
2059 >>> print(LUT)
2060 LUT3D - Unity 33
2061 ----------------
2062 <BLANKLINE>
2063 Dimensions : 3
2064 Domain : [[ 0. 0. 0.]
2065 [ 1. 1. 1.]]
2066 Size : (33, 33, 33, 3)
2067 >>> print(LUT.invert())
2068 LUT3D - Unity 33 - Inverse
2069 --------------------------
2070 <BLANKLINE>
2071 Dimensions : 3
2072 Domain : [[ 0. 0. 0.]
2073 [ 1. 1. 1.]]
2074 Size : (108, 108, 108, 3)
2075 """
2077 from scipy.ndimage import gaussian_filter # noqa: PLC0415
2078 from scipy.spatial import KDTree # noqa: PLC0415
2080 if self.is_domain_explicit():
2081 error = 'Inverting a "LUT3D" with an explicit domain is not implemented!'
2083 raise NotImplementedError(error)
2085 interpolator = kwargs.get("interpolator", table_interpolation_trilinear)
2086 query_size = kwargs.get("query_size", 8)
2087 gamma = kwargs.get("gamma", 3.0)
2088 sigma = kwargs.get("sigma", 0.7)
2089 tau = kwargs.get("tau", 0.75)
2090 oversampling = kwargs.get("oversampling", 1.2)
2092 LUT = self.copy()
2093 source_size = LUT.size
2094 target_size = kwargs.get("size", (as_int(2 ** (np.sqrt(source_size) + 1) + 1)))
2095 sampling_size = int(target_size * oversampling)
2097 if target_size > 129: # pragma: no cover
2098 usage_warning("LUT3D inverse computation time could be excessive!")
2100 # "LUT_t" is an intermediate LUT with oversampling to better capture
2101 # the LUT's transformation, especially in regions with high compression.
2102 # Sampling factor of 1.2 is based on Jacobian analysis: captures 80%
2103 # of theoretical benefit at 30% of computational cost.
2104 LUT_t = LUT3D(size=sampling_size, domain=LUT.domain)
2105 table = np.reshape(LUT_t.table, (-1, 3))
2106 LUT_t.table = LUT.apply(LUT_t.table, interpolator=interpolator)
2108 tree = KDTree(np.reshape(LUT_t.table, (-1, 3)))
2110 # "LUT_q" stores the inverse LUT with improved interpolation.
2111 # Query at the target resolution (output size).
2112 LUT_q = LUT3D(size=target_size, domain=LUT.domain)
2113 query_points = np.reshape(LUT_q.table, (-1, 3))
2115 distances, indices = tree.query(query_points, query_size)
2117 if query_size == 1:
2118 # Single nearest neighbor - no interpolation needed
2119 LUT_q.table = np.reshape(
2120 table[indices], (target_size, target_size, target_size, 3)
2121 )
2122 else:
2123 # Shepard's method (inverse distance weighting) for smooth interpolation.
2124 # Uses w_i = 1 / d_i^(1/gamma) where gamma controls the falloff rate.
2125 # Higher gamma (e.g., 2.0-4.0) creates smoother gradients by blending more
2126 # globally, while lower gamma (e.g., 0.25-0.5) creates sharper transitions.
2127 power = 1.0 / gamma
2128 distances = cast("NDArrayFloat", distances)
2129 weights = 1.0 / (distances + EPSILON) ** power
2130 weights = weights / np.sum(weights, axis=1, keepdims=True)
2132 # Weighted average: sum over neighbors dimension
2133 weighted_table = np.sum(table[indices] * weights[..., np.newaxis], axis=1)
2135 LUT_q.table = np.reshape(
2136 weighted_table,
2137 (target_size, target_size, target_size, 3),
2138 )
2140 # Apply iterative adaptive smoothing based on gradient magnitude.
2141 # Smooths only high-gradient regions (banding artifacts) while preserving
2142 # quality in smooth regions. Multiple iterations with gradient recomputation
2143 # allow smoothing to adapt as the LUT evolves.
2144 if sigma > 0:
2146 def extrapolate(data_3d: NDArrayFloat, pad_width: int) -> NDArrayFloat:
2147 """
2148 Pad the 3D array with linear extrapolation based on edge gradients.
2150 For each axis, extrapolate using:
2151 value[edge + i] = value[edge] + i * gradient
2153 This preserves boundary values much better than reflect/mirror modes.
2154 """
2156 result = data_3d
2158 for axis in range(3):
2159 # Compute edge gradients
2160 edge_lo = np.take(result, [0], axis=axis)
2161 edge_hi = np.take(result, [-1], axis=axis)
2162 grad_lo = edge_lo - np.take(result, [1], axis=axis)
2163 grad_hi = edge_hi - np.take(result, [-2], axis=axis)
2165 # Create padding using linear extrapolation
2166 pad_lo = [edge_lo + (i + 1) * grad_lo for i in range(pad_width)]
2167 pad_hi = [edge_hi + (i + 1) * grad_hi for i in range(pad_width)]
2169 # Concatenate (reverse low padding)
2170 result = np.concatenate([*pad_lo[::-1], result, *pad_hi], axis=axis)
2172 return result
2174 # Iterative smoothing: apply multiple passes with gradient recomputation.
2175 # Each iteration adapts to the evolving LUT state, providing better
2176 # artifact reduction than a single strong blur.
2177 iterations = kwargs.get("iterations", 10)
2178 pad_width = 10
2180 for _ in range(iterations):
2181 # Recompute gradient magnitude at each iteration to adapt
2182 # to the current LUT state
2183 gradient_magnitude = np.zeros(LUT_q.table.shape[:3])
2185 for i in range(3):
2186 gx = np.gradient(LUT_q.table[..., i], axis=0)
2187 gy = np.gradient(LUT_q.table[..., i], axis=1)
2188 gz = np.gradient(LUT_q.table[..., i], axis=2)
2190 gradient_magnitude += np.sqrt(gx**2 + gy**2 + gz**2)
2192 gradient_magnitude /= 3.0
2194 # Identify high-gradient regions using percentile threshold
2195 threshold = np.percentile(gradient_magnitude, tau * 100)
2197 # Apply Gaussian blur with linear extrapolation padding
2198 for i in range(3):
2199 # Pad with linear extrapolation (recomputed each iteration)
2200 table_p = extrapolate(LUT_q.table[..., i], pad_width)
2201 # Filter the padded data
2202 table_f = gaussian_filter(table_p, sigma=sigma)
2203 # Un-pad
2204 table_e = table_f[
2205 pad_width:-pad_width,
2206 pad_width:-pad_width,
2207 pad_width:-pad_width,
2208 ]
2209 # Apply selectively to high-gradient regions only
2210 LUT_q.table[..., i] = np.where(
2211 gradient_magnitude > threshold,
2212 table_e,
2213 LUT_q.table[..., i],
2214 )
2216 LUT_q.name = f"{self.name} - Inverse"
2218 return LUT_q
2220 def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat:
2221 """
2222 Apply the *LUT* to the specified *RGB* colourspace array using the
2223 specified interpolation method.
2225 Parameters
2226 ----------
2227 RGB
2228 *RGB* colourspace array to apply the *LUT* onto.
2230 Other Parameters
2231 ----------------
2232 direction
2233 Whether the *LUT* should be applied in the forward or inverse
2234 direction.
2235 interpolator
2236 Interpolator object to use as the interpolating function.
2237 interpolator_kwargs
2238 Arguments to use when calling the interpolating function.
2239 query_size
2240 Number of points to query in the KDTree, with their mean
2241 computed to produce a smoother result.
2242 size
2243 Size of the inverse *LUT*. With the specified implementation,
2244 it is recommended to double the size of the inverse *LUT* to
2245 provide a smoother result. If ``size`` is not specified,
2246 :math:`2^{\\sqrt{size_{LUT}} + 1} + 1` will be used instead.
2248 Returns
2249 -------
2250 :class:`numpy.ndarray`
2251 Interpolated *RGB* colourspace array.
2253 Examples
2254 --------
2255 >>> LUT = LUT3D(LUT3D.linear_table() ** (1 / 2.2))
2256 >>> RGB = np.array([0.18, 0.18, 0.18])
2257 >>> LUT.apply(RGB) # doctest: +ELLIPSIS
2258 array([ 0.4583277..., 0.4583277..., 0.4583277...])
2259 >>> LUT.apply(LUT.apply(RGB), direction="Inverse")
2260 ... # doctest: +ELLIPSIS +SKIP
2261 array([ 0.1799897..., 0.1796077..., 0.1795868...])
2262 >>> from colour.algebra import spow
2263 >>> domain = np.array(
2264 ... [
2265 ... [-0.1, -0.2, -0.4],
2266 ... [0.3, 1.4, 6.0],
2267 ... [0.7, 3.0, np.nan],
2268 ... [1.1, np.nan, np.nan],
2269 ... [1.5, np.nan, np.nan],
2270 ... ]
2271 ... )
2272 >>> table = spow(LUT3D.linear_table(domain=domain), 1 / 2.2)
2273 >>> LUT = LUT3D(table, domain=domain)
2274 >>> RGB = np.array([0.18, 0.18, 0.18])
2275 >>> LUT.apply(RGB) # doctest: +ELLIPSIS
2276 array([ 0.2996370..., -0.0901332..., -0.3949770...])
2277 """
2279 direction = validate_method(
2280 kwargs.get("direction", "Forward"), ("Forward", "Inverse")
2281 )
2283 interpolator = kwargs.get("interpolator", table_interpolation_trilinear)
2284 interpolator_kwargs = kwargs.get("interpolator_kwargs", {})
2286 R, G, B = tsplit(RGB)
2288 settings = {"interpolator": interpolator}
2289 settings.update(**kwargs)
2290 LUT = self.invert(**settings) if direction == "inverse" else self
2292 if LUT.is_domain_explicit():
2293 domain_min = LUT.domain[0, ...]
2294 domain_max = [
2295 axes[: (~np.isnan(axes)).cumsum().argmax() + 1][-1]
2296 for axes in np.transpose(LUT.domain)
2297 ]
2298 usage_warning(
2299 f'"LUT" was defined with an explicit domain but requires an '
2300 f"implicit domain to be applied. The following domain will be "
2301 f"used: {np.vstack([domain_min, domain_max])}"
2302 )
2303 else:
2304 domain_min, domain_max = LUT.domain
2306 RGB_l = [
2307 linear_conversion(j, (domain_min[i], domain_max[i]), (0, 1))
2308 for i, j in enumerate((R, G, B))
2309 ]
2311 return interpolator(tstack(RGB_l), LUT.table, **interpolator_kwargs)
2314def LUT_to_LUT(
2315 LUT: AbstractLUT,
2316 cls: Type[AbstractLUT],
2317 force_conversion: bool = False,
2318 **kwargs: Any,
2319) -> AbstractLUT:
2320 """
2321 Convert a specified *LUT* to the specified ``cls`` class instance.
2323 This function facilitates conversion between different LUT class types,
2324 including LUT1D, LUT3x1D, and LUT3D instances. Some conversions may be
2325 destructive and require explicit force conversion.
2327 Parameters
2328 ----------
2329 LUT
2330 *LUT* to convert.
2331 cls
2332 Target *LUT* class type for conversion.
2333 force_conversion
2334 Whether to force the conversion if it would be destructive.
2336 Other Parameters
2337 ----------------
2338 channel_weights
2339 Channel weights in case of a downcast from a :class:`LUT3x1D` or
2340 :class:`LUT3D` class instance.
2341 interpolator
2342 Interpolator class type to use as interpolating function.
2343 interpolator_kwargs
2344 Arguments to use when instantiating the interpolating function.
2345 size
2346 Expected table size in case of an upcast to or a downcast from a
2347 :class:`LUT3D` class instance.
2349 Returns
2350 -------
2351 :class:`colour.LUT1D` or :class:`colour.LUT3x1D` or :class:`colour.LUT3D`
2352 Converted *LUT* class instance.
2354 Warnings
2355 --------
2356 Some conversions are destructive and raise a :class:`ValueError` exception
2357 by default.
2359 Raises
2360 ------
2361 ValueError
2362 If the conversion is destructive.
2364 Examples
2365 --------
2366 >>> print(LUT_to_LUT(LUT1D(), LUT3D, force_conversion=True))
2367 LUT3D - Unity 10 - Converted 1D to 3D
2368 -------------------------------------
2369 <BLANKLINE>
2370 Dimensions : 3
2371 Domain : [[ 0. 0. 0.]
2372 [ 1. 1. 1.]]
2373 Size : (33, 33, 33, 3)
2374 >>> print(LUT_to_LUT(LUT3x1D(), LUT1D, force_conversion=True))
2375 LUT1D - Unity 10 - Converted 3x1D to 1D
2376 ---------------------------------------
2377 <BLANKLINE>
2378 Dimensions : 1
2379 Domain : [ 0. 1.]
2380 Size : (10,)
2381 >>> print(LUT_to_LUT(LUT3D(), LUT1D, force_conversion=True))
2382 LUT1D - Unity 33 - Converted 3D to 1D
2383 -------------------------------------
2384 <BLANKLINE>
2385 Dimensions : 1
2386 Domain : [ 0. 1.]
2387 Size : (10,)
2388 """
2390 ranks = {LUT1D: 1, LUT3x1D: 2, LUT3D: 3}
2391 path = (ranks[LUT.__class__], ranks[cls])
2392 path_verbose = [f"{element}D" if element != 2 else "3x1D" for element in path]
2393 if path in ((1, 3), (2, 1), (2, 3), (3, 1), (3, 2)) and not force_conversion:
2394 error = (
2395 f'Conversion of a "LUT" {path_verbose[0]} to a "LUT" '
2396 f"{path_verbose[1]} is destructive, please use the "
2397 f'"force_conversion" argument to proceed!'
2398 )
2400 raise ValueError(error)
2402 suffix = f" - Converted {path_verbose[0]} to {path_verbose[1]}"
2403 name = f"{LUT.name}{suffix}"
2405 # Same dimension conversion, returning a copy.
2406 if len(set(path)) == 1:
2407 LUT = LUT.copy()
2408 LUT.name = name
2409 else:
2410 size = kwargs.get("size", 33 if cls is LUT3D else 10)
2411 kwargs.pop("size", None)
2413 channel_weights = as_float_array(kwargs.get("channel_weights", full(3, 1 / 3)))
2414 kwargs.pop("channel_weights", None)
2416 if isinstance(LUT, LUT1D):
2417 if cls is LUT3x1D:
2418 domain = tstack([LUT.domain, LUT.domain, LUT.domain])
2419 table = tstack([LUT.table, LUT.table, LUT.table])
2420 elif cls is LUT3D:
2421 domain = tstack([LUT.domain, LUT.domain, LUT.domain])
2422 table = LUT3D.linear_table(size, domain)
2423 table = LUT.apply(table, **kwargs)
2424 elif isinstance(LUT, LUT3x1D):
2425 if cls is LUT1D:
2426 domain = np.sum(LUT.domain * channel_weights, axis=-1)
2427 table = np.sum(LUT.table * channel_weights, axis=-1)
2428 elif cls is LUT3D:
2429 domain = LUT.domain
2430 table = LUT3D.linear_table(size, domain)
2431 table = LUT.apply(table, **kwargs)
2432 elif isinstance(LUT, LUT3D):
2433 if cls is LUT1D:
2434 domain = np.sum(LUT.domain * channel_weights, axis=-1)
2435 table = LUT1D.linear_table(size, domain)
2436 table = LUT.apply(tstack([table, table, table]), **kwargs)
2437 table = np.sum(table * channel_weights, axis=-1)
2438 elif cls is LUT3x1D:
2439 domain = LUT.domain
2440 table = LUT3x1D.linear_table(size, domain)
2441 table = LUT.apply(table, **kwargs)
2443 LUT = cls(
2444 table=table,
2445 name=name,
2446 domain=domain,
2447 size=table.shape[0],
2448 comments=LUT.comments,
2449 )
2451 return LUT