Source code for colour.io.luts.lut

"""
LUT Processing
==============

Define the classes and definitions for *Look-Up Table* (*LUT*) processing
operations.

-   :class:`colour.LUT1D`: One-dimensional lookup table for single-channel
    transformations
-   :class:`colour.LUT3x1D`: Three parallel one-dimensional lookup tables
    for independent RGB channel processing
-   :class:`colour.LUT3D`: Three-dimensional lookup table for complex colour
    space transformations
-   :class:`colour.io.LUT_to_LUT`: Utility for converting between different
    LUT formats and types
"""

from __future__ import annotations

import typing
from abc import ABC, abstractmethod
from copy import deepcopy
from operator import pow  # noqa: A004
from operator import add, iadd, imul, ipow, isub, itruediv, mul, sub, truediv

import numpy as np

from colour.algebra import (
    Extrapolator,
    LinearInterpolator,
    linear_conversion,
    table_interpolation_trilinear,
)
from colour.constants import EPSILON

if typing.TYPE_CHECKING:
    from colour.hints import (
        Any,
        ArrayLike,
        Literal,
        NDArrayFloat,
        Self,
        Sequence,
        Type,
    )

from colour.hints import List, cast
from colour.utilities import (
    as_array,
    as_float_array,
    as_int,
    as_int_array,
    as_int_scalar,
    attest,
    full,
    is_iterable,
    is_numeric,
    multiline_repr,
    multiline_str,
    optional,
    required,
    runtime_warning,
    tsplit,
    tstack,
    usage_warning,
    validate_method,
)

__author__ = "Colour Developers"
__copyright__ = "Copyright 2013 Colour Developers"
__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
__maintainer__ = "Colour Developers"
__email__ = "colour-developers@colour-science.org"
__status__ = "Production"

__all__ = [
    "AbstractLUT",
    "LUT1D",
    "LUT3x1D",
    "LUT3D",
    "LUT_to_LUT",
]


class AbstractLUT(ABC):
    """
    Define the base class for *LUT* (Look-Up Table).

    This is an abstract base class (:class:`ABCMeta`) that must be inherited
    by concrete *LUT* implementations to provide common functionality and
    interface specifications.

    Parameters
    ----------
    table
        Underlying *LUT* table array containing the lookup values.
    name
        *LUT* identifying name.
    dimensions
        *LUT* dimensionality: typically 1 for a 1D *LUT*, 2 for a 3x1D *LUT*,
        and 3 for a 3D *LUT*.
    domain
        *LUT* input domain boundaries, also used to define the instantiation
        time default table domain.
    size
        *LUT* resolution or sampling density, also used to define the
        instantiation time default table size.
    comments
        Additional comments or metadata to associate with the *LUT*.

    Attributes
    ----------
    -   :attr:`~colour.io.luts.lut.AbstractLUT.table`
    -   :attr:`~colour.io.luts.lut.AbstractLUT.name`
    -   :attr:`~colour.io.luts.lut.AbstractLUT.dimensions`
    -   :attr:`~colour.io.luts.lut.AbstractLUT.domain`
    -   :attr:`~colour.io.luts.lut.AbstractLUT.size`
    -   :attr:`~colour.io.luts.lut.AbstractLUT.comments`

    Methods
    -------
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__init__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__str__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__repr__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__eq__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__ne__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__add__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__iadd__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__sub__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__isub__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__mul__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__imul__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__div__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__idiv__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__pow__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.__ipow__`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.arithmetical_operation`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.is_domain_explicit`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.linear_table`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.copy`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.invert`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.apply`
    -   :meth:`~colour.io.luts.lut.AbstractLUT.convert`
    """

    def __init__(
        self,
        table: ArrayLike | None = None,
        name: str | None = None,
        dimensions: int | None = None,
        domain: ArrayLike | None = None,
        size: ArrayLike | None = None,
        comments: Sequence | None = None,
    ) -> None:
        self._name: str = f"Unity {size!r}" if table is None else f"{id(self)}"
        self.name = optional(name, self._name)
        self._dimensions = optional(dimensions, 0)
        self._table: NDArrayFloat = self.linear_table(
            optional(size, 0), optional(domain, np.array([]))
        )
        self.table = optional(table, self._table)
        self._domain: NDArrayFloat = np.array([])
        self.domain = optional(domain, self._domain)
        self._comments: list = []
        self.comments = cast("list", optional(comments, self._comments))

    @property
    def table(self) -> NDArrayFloat:
        """
        Getter and setter for the underlying *LUT* table.

        Access or modify the lookup table data structure that defines the
        transformation mapping for this LUT instance.

        Parameters
        ----------
        value
            Value to set the underlying *LUT* table with.

        Returns
        -------
        :class:`numpy.ndarray`
            Underlying *LUT* table.
        """

        return self._table

    @table.setter
    def table(self, value: ArrayLike) -> None:
        """Setter for the **self.table** property."""

        self._table = self._validate_table(value)

    @property
    def name(self) -> str:
        """
        Getter and setter for the *LUT* name.

        Parameters
        ----------
        value
            Value to set the *LUT* name with.

        Returns
        -------
        :class:`str`
            *LUT* name.
        """

        return self._name

    @name.setter
    def name(self, value: str) -> None:
        """Setter for the **self.name** property."""

        attest(
            isinstance(value, str),
            f'"name" property: "{value}" type is not "str"!',
        )

        self._name = value

    @property
    def domain(self) -> NDArrayFloat:
        """
        Getter and setter for the *LUT* domain.

        The domain defines the input coordinate space for the lookup table,
        specifying the valid range of input values that can be interpolated.

        Parameters
        ----------
        value
            Value to set the *LUT* domain with.

        Returns
        -------
        :class:`numpy.ndarray`
            *LUT* domain.
        """

        return self._domain

    @domain.setter
    def domain(self, value: ArrayLike) -> None:
        """Setter for the **self.domain** property."""

        self._domain = self._validate_domain(value)

    @property
    def dimensions(self) -> int:
        """
        Getter for the *LUT* dimensions.

        Returns
        -------
        :class:`int`
            *LUT* dimensions.
        """

        return self._dimensions

    @property
    def size(self) -> int:
        """
        Getter for the *LUT* size.

        Returns
        -------
        :class:`int`
            *LUT* size.
        """

        return self._table.shape[0]

    @property
    def comments(self) -> list:
        """
        Getter and setter for the *LUT* comments.

        Parameters
        ----------
        value
            Value to set the *LUT* comments with.

        Returns
        -------
        :class:`list`
            *LUT* comments.
        """

        return self._comments

    @comments.setter
    def comments(self, value: Sequence) -> None:
        """Setter for the **self.comments** property."""

        attest(
            is_iterable(value),
            f'"comments" property: "{value}" must be a sequence!',
        )

        self._comments = list(value)

    def __str__(self) -> str:
        """
        Return a formatted string representation of the *LUT*.

        Returns
        -------
        :class:`str`
            Formatted string representation.
        """

        attributes = [
            {
                "formatter": lambda x: (  # noqa: ARG005
                    f"{self.__class__.__name__} - {self.name}"
                ),
                "section": True,
            },
            {"line_break": True},
            {"name": "dimensions", "label": "Dimensions"},
            {"name": "domain", "label": "Domain"},
            {
                "label": "Size",
                "formatter": lambda x: str(self.table.shape),  # noqa: ARG005
            },
        ]

        if self.comments:
            attributes.append(
                {
                    "formatter": lambda x: "\n".join(  # noqa: ARG005
                        [
                            f"Comment {str(i + 1).zfill(2)} : {comment}"
                            for i, comment in enumerate(self.comments)
                        ]
                    ),
                }
            )

        return multiline_str(self, cast("List[dict]", attributes))

    def __repr__(self) -> str:
        """
        Return an evaluable string representation of the *LUT*.

        This method provides a string that, when evaluated, recreates the
        *LUT* object with its current state and configuration.

        Returns
        -------
        :class:`str`
            Evaluable string representation.
        """

        attributes = [
            {"name": "table"},
            {"name": "name"},
            {"name": "domain"},
            {"name": "size"},
        ]

        if self.comments:
            attributes.append({"name": "comments"})

        return multiline_repr(self, attributes)

    __hash__ = None  # pyright: ignore

    def __eq__(self, other: object) -> bool:
        """
        Return whether the *LUT* is equal to the specified other object.

        Parameters
        ----------
        other
            Object to test whether it is equal to the *LUT*.

        Returns
        -------
        :class:`bool`
            Whether the specified object is equal to the *LUT*.
        """

        return isinstance(other, AbstractLUT) and all(
            [
                np.array_equal(self.table, other.table),
                np.array_equal(self.domain, other.domain),
            ]
        )

    def __ne__(self, other: object) -> bool:
        """
        Determine whether the *LUT* is not equal to the specified other
        object.

        Parameters
        ----------
        other
            Object to test for inequality with the *LUT*.

        Returns
        -------
        :class:`bool`
            Whether the specified object is not equal to the *LUT*.
        """

        return not (self == other)

    def __add__(self, a: ArrayLike | AbstractLUT) -> AbstractLUT:
        """
        Implement support for addition.

        Parameters
        ----------
        a
            *a* variable to add.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            Variable added *LUT*.
        """

        return self.arithmetical_operation(a, "+")

    def __iadd__(self, a: ArrayLike | AbstractLUT) -> Self:
        """
        Implement support for in-place addition.

        Add the specified operand to this *LUT* in-place, modifying the
        current instance rather than creating a new one.

        Parameters
        ----------
        a
            Operand to add in-place. Can be a numeric array or another
            *LUT* instance with compatible dimensions.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            Current *LUT* instance with the addition applied in-place.
        """

        return self.arithmetical_operation(a, "+", True)

    def __sub__(self, a: ArrayLike | AbstractLUT) -> Self:
        """
        Implement support for subtraction.

        Parameters
        ----------
        a
            Variable, array or *LUT* to subtract from the current *LUT*.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            Variable subtracted *LUT*.
        """

        return self.arithmetical_operation(a, "-")

    def __isub__(self, a: ArrayLike | AbstractLUT) -> Self:
        """
        Implement support for in-place subtraction.

        Parameters
        ----------
        a
            :math:`a` variable to subtract in-place.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            In-place variable subtracted *LUT*.
        """

        return self.arithmetical_operation(a, "-", True)

    def __mul__(self, a: ArrayLike | AbstractLUT) -> Self:
        """
        Implement support for multiplication.

        Parameters
        ----------
        a
            Variable to multiply with the *LUT*. Can be a numeric array or
            another *LUT* instance.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            Variable multiplied *LUT*.
        """

        return self.arithmetical_operation(a, "*")

    def __imul__(self, a: ArrayLike | AbstractLUT) -> Self:
        """
        Implement support for in-place multiplication.

        Parameters
        ----------
        a
            :math:`a` variable to multiply by in-place.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            In-place variable multiplied *LUT*.
        """

        return self.arithmetical_operation(a, "*", True)

    def __div__(self, a: ArrayLike | AbstractLUT) -> Self:
        """
        Implement support for division.

        Parameters
        ----------
        a
            :math:`a` variable to divide by.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            Variable divided *LUT*.
        """

        return self.arithmetical_operation(a, "/")

    def __idiv__(self, a: ArrayLike | AbstractLUT) -> Self:
        """
        Perform in-place division of the *LUT* by the specified operand.

        Parameters
        ----------
        a
            Operand to divide the *LUT* by in-place.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            Current *LUT* instance with the division applied in-place.
        """

        return self.arithmetical_operation(a, "/", True)

    __itruediv__ = __idiv__
    __truediv__ = __div__

    def __pow__(self, a: ArrayLike | AbstractLUT) -> Self:
        """
        Implement support for exponentiation.

        Parameters
        ----------
        a
            :math:`a` variable to exponentiate by.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            Variable exponentiated *LUT*.
        """

        return self.arithmetical_operation(a, "**")

    def __ipow__(self, a: ArrayLike | AbstractLUT) -> Self:
        """
        Implement support for in-place exponentiation.

        Parameters
        ----------
        a
            :math:`a` variable to exponentiate by in-place.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            In-place variable exponentiated *LUT*.
        """

        return self.arithmetical_operation(a, "**", True)

    def arithmetical_operation(
        self,
        a: ArrayLike | AbstractLUT,
        operation: Literal["+", "-", "*", "/", "**"],
        in_place: bool = False,
    ) -> Self:
        """
        Perform the specified arithmetical operation with the :math:`a`
        operand.

        Execute the requested mathematical operation between this *LUT*
        instance and the specified operand. The operation can be performed
        either on a copy of the *LUT* or in-place on the current instance.
        This method must be reimplemented by sub-classes to handle their
        specific table structures.

        Parameters
        ----------
        a
            Operand for the arithmetical operation. Can be either a numeric
            array or another *LUT* instance with compatible dimensions.
        operation
            Arithmetical operation to perform. Supported operations are
            addition (``+``), subtraction (``-``), multiplication (``*``),
            division (``/``), and exponentiation (``**``).
        in_place
            Whether to perform the operation in-place on the current *LUT*
            instance (``True``) or on a copy (``False``).

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            Modified *LUT* instance. If ``in_place`` is ``True``, returns
            the current instance after modification. If ``False``, returns
            a new modified copy.
        """

        operator, ioperator = {
            "+": (add, iadd),
            "-": (sub, isub),
            "*": (mul, imul),
            "/": (truediv, itruediv),
            "**": (pow, ipow),
        }[operation]

        if in_place:
            operand = a.table if isinstance(a, AbstractLUT) else as_float_array(a)

            self.table = operator(self.table, operand)

            return self

        return ioperator(self.copy(), a)

    @abstractmethod
    def _validate_table(self, table: ArrayLike) -> NDArrayFloat:
        """
        Validate the specified table according to *LUT* dimensions.

        Parameters
        ----------
        table
            Table to validate.

        Returns
        -------
        :class:`numpy.ndarray`
            Validated table as a :class:`ndarray` instance.
        """

    @abstractmethod
    def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat:
        """
        Validate specified domain according to *LUT* dimensions.

        Parameters
        ----------
        domain
            Domain to validate.

        Returns
        -------
        :class:`numpy.ndarray`
            Validated domain as a :class:`ndarray` instance.
        """

    @abstractmethod
    def is_domain_explicit(self) -> bool:
        """
        Return whether the *LUT* domain is explicit (or implicit).

        An implicit domain is defined by its shape only::

            [[0 1]
             [0 1]
             [0 1]]

        While an explicit domain defines every single discrete sample::

            [[0.0 0.0 0.0]
             [0.1 0.1 0.1]
             [0.2 0.2 0.2]
             [0.3 0.3 0.3]
             [0.4 0.4 0.4]
             [0.8 0.8 0.8]
             [1.0 1.0 1.0]]

        Returns
        -------
        :class:`bool`
            Is *LUT* domain explicit.
        """

    @staticmethod
    @abstractmethod
    def linear_table(
        size: ArrayLike | None = None,
        domain: ArrayLike | None = None,
    ) -> NDArrayFloat:
        """
        Generate a linear table of the specified size according to LUT dimensions.

        Parameters
        ----------
        size
            Expected table size, for a 1D *LUT*, the number of output samples
            :math:`n` is equal to ``size``, for a 3x1D *LUT* :math:`n` is equal
            to ``size * 3`` or ``size[0] + size[1] + size[2]``, for a 3D *LUT*
            :math:`n` is equal to ``size**3 * 3`` or
            ``size[0] * size[1] * size[2] * 3``.
        domain
            Domain of the table.

        Returns
        -------
        :class:`numpy.ndarray`
            Linear table.
        """

    def copy(self) -> AbstractLUT:
        """
        Return a copy of the sub-class instance.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            Copy of the LUT instance.
        """

        return deepcopy(self)

    @abstractmethod
    def invert(self, **kwargs: Any) -> AbstractLUT:
        """
        Compute and return an inverse copy of the *LUT*.

        Other Parameters
        ----------------
        kwargs
            Keywords arguments.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            Inverse *LUT* class instance.
        """

    @abstractmethod
    def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat:
        """
        Apply the *LUT* to the specified *RGB* colourspace array using the
        specified method.

        Parameters
        ----------
        RGB
            *RGB* colourspace array to apply the *LUT* onto.

        Other Parameters
        ----------------
        direction
            Whether the *LUT* should be applied in the forward or inverse
            direction.
        extrapolator
            Extrapolator class type or object to use as extrapolating
            function.
        extrapolator_kwargs
            Arguments to use when instantiating or calling the extrapolating
            function.
        interpolator
            Interpolator class type or object to use as interpolating
            function.
        interpolator_kwargs
            Arguments to use when instantiating or calling the interpolating
            function.

        Returns
        -------
        :class:`numpy.ndarray`
            Interpolated *RGB* colourspace array.
        """

    def convert(
        self,
        cls: Type[AbstractLUT],
        force_conversion: bool = False,
        **kwargs: Any,
    ) -> AbstractLUT:
        """
        Convert the *LUT* to the specified ``cls`` class instance.

        Parameters
        ----------
        cls
            *LUT* class instance.
        force_conversion
            Whether to force the conversion as it might be destructive.

        Other Parameters
        ----------------
        interpolator
            Interpolator class type to use as interpolating function.
        interpolator_kwargs
            Arguments to use when instantiating the interpolating function.
        size
            Expected table size in case of an upcast to or a downcast from a
            :class:`LUT3D` class instance.

        Returns
        -------
        :class:`colour.io.luts.lut.AbstractLUT`
            Converted *LUT* class instance.

        Warnings
        --------
        Some conversions are destructive and raise a :class:`ValueError`
        exception by default.

        Raises
        ------
        ValueError
            If the conversion is destructive.
        """

        return LUT_to_LUT(self, cls, force_conversion, **kwargs)


[docs] class LUT1D(AbstractLUT): """ Define the base class for a 1D *LUT*. A 1D (one-dimensional) lookup table provides a mapping function from input values to output values through interpolation of discrete table entries. This class is commonly used for tone mapping, gamma correction, and other single-channel transformations where the output depends solely on the input value. Parameters ---------- table Underlying *LUT* table. name *LUT* name. domain *LUT* domain, also used to define the instantiation time default table domain. size Size of the instantiation time default table, default to 10. comments Comments to add to the *LUT*. Methods ------- - :meth:`~colour.LUT1D.__init__` - :meth:`~colour.LUT1D.is_domain_explicit` - :meth:`~colour.LUT1D.linear_table` - :meth:`~colour.LUT1D.invert` - :meth:`~colour.LUT1D.apply` Examples -------- Instantiating a unity LUT with a table with 16 elements: >>> print(LUT1D(size=16)) LUT1D - Unity 16 ---------------- <BLANKLINE> Dimensions : 1 Domain : [0. 1.] Size : (16,) Instantiating a LUT using a custom table with 16 elements: >>> print(LUT1D(LUT1D.linear_table(16) ** (1 / 2.2))) # doctest: +ELLIPSIS LUT1D - ... --------... <BLANKLINE> Dimensions : 1 Domain : [0. 1.] Size : (16,) Instantiating a LUT using a custom table with 16 elements, custom name, custom domain and comments: >>> from colour.algebra import spow >>> domain = np.array([-0.1, 1.5]) >>> print( ... LUT1D( ... spow(LUT1D.linear_table(16, domain), 1 / 2.2), ... "My LUT", ... domain, ... comments=["A first comment.", "A second comment."], ... ) ... ) LUT1D - My LUT -------------- <BLANKLINE> Dimensions : 1 Domain : [-0.1 1.5] Size : (16,) Comment 01 : A first comment. Comment 02 : A second comment. """
[docs] def __init__( self, table: ArrayLike | None = None, name: str | None = None, domain: ArrayLike | None = None, size: ArrayLike | None = None, comments: Sequence | None = None, ) -> None: domain = as_float_array(optional(domain, np.array([0, 1]))) size = optional(size, 10) super().__init__(table, name, 1, domain, size, comments)
def _validate_table(self, table: ArrayLike) -> NDArrayFloat: """ Validate that the specified table is a 1D array. Parameters ---------- table Table to validate. Returns ------- :class:`numpy.ndarray` Validated table as a :class:`numpy.ndarray` instance. """ table = as_float_array(table) attest(len(table.shape) == 1, "The table must be a 1D array!") return table def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat: """ Validate specified domain. Parameters ---------- domain Domain to validate. Returns ------- :class:`numpy.ndarray` Validated domain as a :class:`ndarray` instance. """ domain = as_float_array(domain) attest(len(domain.shape) == 1, "The domain must be a 1D array!") attest( domain.shape[0] >= 2, "The domain column count must be equal or greater than 2!", ) return domain
[docs] def is_domain_explicit(self) -> bool: """ Return whether the *LUT* domain is explicit (or implicit). An implicit domain is defined by its shape only:: [0 1] While an explicit domain defines every single discrete samples:: [0.0 0.1 0.2 0.4 0.8 1.0] Returns ------- :class:`bool` Is *LUT* domain explicit. Examples -------- >>> LUT1D().is_domain_explicit() False >>> table = domain = np.linspace(0, 1, 10) >>> LUT1D(table, domain=domain).is_domain_explicit() True """ return len(self.domain) != 2
[docs] @staticmethod def linear_table( size: ArrayLike | None = None, domain: ArrayLike | None = None, ) -> NDArrayFloat: """ Generate a linear table with the specified number of output samples :math:`n`. The table contains linearly spaced values across the specified domain. If no domain is provided, the default domain [0, 1] is used. Parameters ---------- size Number of samples in the output table. Default is 10. domain Domain boundaries of the table as a 2-element array [min, max] or an array of values whose minimum and maximum define the domain. Default is [0, 1]. Returns ------- :class:`numpy.ndarray` Linear table containing ``size`` evenly spaced samples across the specified domain. Examples -------- >>> LUT1D.linear_table(5, np.array([-0.1, 1.5])) array([-0.1, 0.3, 0.7, 1.1, 1.5]) >>> LUT1D.linear_table(domain=np.linspace(-0.1, 1.5, 5)) array([-0.1, 0.3, 0.7, 1.1, 1.5]) """ size = optional(size, 10) domain = as_float_array(optional(domain, np.array([0, 1]))) if len(domain) != 2: return domain attest(is_numeric(size), "Linear table size must be a numeric!") return np.linspace(domain[0], domain[1], as_int_scalar(size))
[docs] def invert(self, **kwargs: Any) -> LUT1D: # noqa: ARG002 """ Compute and return an inverse copy of the *LUT*. Other Parameters ---------------- kwargs Keywords arguments, only specified for signature compatibility with the :meth:`AbstractLUT.invert` method. Returns ------- :class:`colour.LUT1D` Inverse *LUT* class instance. Examples -------- >>> LUT = LUT1D(LUT1D.linear_table() ** (1 / 2.2)) >>> print(LUT.table) # doctest: +ELLIPSIS [0. ... 0.3683438... 0.5047603... 0.6069133... \ 0.6916988... 0.7655385... 0.8316843... 0.8920493... 0.9478701... 1. ...] >>> print(LUT.invert()) # doctest: +ELLIPSIS LUT1D - ... - Inverse --------...---------- <BLANKLINE> Dimensions : 1 Domain : [0. 0.3683438... 0.5047603... 0.6069133... \ 0.6916988... 0.7655385... 0.8316843... 0.8920493... 0.9478701... 1. ...] Size : (10,) >>> print(LUT.invert().table) # doctest: +ELLIPSIS [0. ... 0.1111111... 0.2222222... 0.3333333... \ 0.4444444... 0.5555555... 0.6666666... 0.7777777... 0.8888888... 1. ...] """ if self.is_domain_explicit(): domain = self.domain else: domain_min, domain_max = self.domain domain = np.linspace(domain_min, domain_max, self.size) return LUT1D( table=domain, name=f"{self.name} - Inverse", domain=self.table, )
[docs] def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat: """ Apply the *LUT* to the specified *RGB* colourspace array using the specified method. Parameters ---------- RGB *RGB* colourspace array to apply the *LUT* onto. Other Parameters ---------------- direction Whether the *LUT* should be applied in the forward or inverse direction. extrapolator Extrapolator class type or object to use as extrapolating function. extrapolator_kwargs Arguments to use when instantiating or calling the extrapolating function. interpolator Interpolator class type to use as interpolating function. interpolator_kwargs Arguments to use when instantiating the interpolating function. Returns ------- :class:`numpy.ndarray` Interpolated *RGB* colourspace array. Examples -------- >>> LUT = LUT1D(LUT1D.linear_table() ** (1 / 2.2)) >>> RGB = np.array([0.18, 0.18, 0.18]) *LUT* applied to the specified *RGB* colourspace in the forward direction: >>> LUT.apply(RGB) # doctest: +ELLIPSIS array([0.4529220..., 0.4529220..., 0.4529220...]) *LUT* applied to the modified *RGB* colourspace in the inverse direction: >>> LUT.apply(LUT.apply(RGB), direction="Inverse") ... # doctest: +ELLIPSIS array([0.18..., 0.18..., 0.18...]) """ direction = validate_method( kwargs.get("direction", "Forward"), ("Forward", "Inverse") ) interpolator = kwargs.get("interpolator", LinearInterpolator) interpolator_kwargs = kwargs.get("interpolator_kwargs", {}) extrapolator = kwargs.get("extrapolator", Extrapolator) extrapolator_kwargs = kwargs.get("extrapolator_kwargs", {}) LUT = self.invert() if direction == "inverse" else self if LUT.is_domain_explicit(): samples = LUT.domain else: domain_min, domain_max = LUT.domain samples = np.linspace(domain_min, domain_max, LUT.size) RGB_interpolator = extrapolator( interpolator(samples, LUT.table, **interpolator_kwargs), **extrapolator_kwargs, ) return RGB_interpolator(RGB)
[docs] class LUT3x1D(AbstractLUT): """ Define the base class for a 3x1D *LUT*. A 3x1D (three-by-one-dimensional) lookup table applies independent transformations to each channel of a three-channel input. Each channel has its own 1D lookup table, enabling per-channel colour corrections and tone mapping operations. Parameters ---------- table Underlying *LUT* table. name *LUT* name. domain *LUT* domain, also used to define the instantiation time default table domain. size Size of the instantiation time default table, default to 10. comments Comments to add to the *LUT*. Methods ------- - :meth:`~colour.LUT3x1D.__init__` - :meth:`~colour.LUT3x1D.is_domain_explicit` - :meth:`~colour.LUT3x1D.linear_table` - :meth:`~colour.LUT3x1D.invert` - :meth:`~colour.LUT3x1D.apply` Examples -------- Instantiating a unity LUT with a table with 16x3 elements: >>> print(LUT3x1D(size=16)) LUT3x1D - Unity 16 ------------------ <BLANKLINE> Dimensions : 2 Domain : [[0. 0. 0.] [1. 1. 1.]] Size : (16, 3) Instantiating a LUT using a custom table with 16x3 elements: >>> print(LUT3x1D(LUT3x1D.linear_table(16) ** (1 / 2.2))) ... # doctest: +ELLIPSIS LUT3x1D - ... ----------... <BLANKLINE> Dimensions : 2 Domain : [[0. 0. 0.] [1. 1. 1.]] Size : (16, 3) Instantiating a LUT using a custom table with 16x3 elements, custom name, custom domain and comments: >>> from colour.algebra import spow >>> domain = np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]) >>> print( ... LUT3x1D( ... spow(LUT3x1D.linear_table(16), 1 / 2.2), ... "My LUT", ... domain, ... comments=["A first comment.", "A second comment."], ... ) ... ) LUT3x1D - My LUT ---------------- <BLANKLINE> Dimensions : 2 Domain : [[-0.1 -0.2 -0.4] [ 1.5 3. 6. ]] Size : (16, 3) Comment 01 : A first comment. Comment 02 : A second comment. """
[docs] def __init__( self, table: ArrayLike | None = None, name: str | None = None, domain: ArrayLike | None = None, size: ArrayLike | None = None, comments: Sequence | None = None, ) -> None: domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]])) size = optional(size, 10) super().__init__(table, name, 2, domain, size, comments)
def _validate_table(self, table: ArrayLike) -> NDArrayFloat: """ Validate specified table is a 3x1D array. Parameters ---------- table Table to validate. Returns ------- :class:`numpy.ndarray` Validated table as a :class:`ndarray` instance. """ table = as_float_array(table) attest(len(table.shape) == 2, "The table must be a 2D array!") return table def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat: """ Validate the specified domain for the lookup table. Parameters ---------- domain Domain to validate. Returns ------- :class:`numpy.ndarray` Validated domain as a :class:`ndarray` instance. """ domain = as_float_array(domain) attest(len(domain.shape) == 2, "The domain must be a 2D array!") attest( domain.shape[0] >= 2, "The domain row count must be equal or greater than 2!", ) attest(domain.shape[1] == 3, "The domain column count must be equal to 3!") return domain
[docs] def is_domain_explicit(self) -> bool: """ Return whether the *LUT* domain is explicit (or implicit). An implicit domain is defined by its shape only:: [[0 1] [0 1] [0 1]] While an explicit domain defines every single discrete samples:: [[0.0 0.0 0.0] [0.1 0.1 0.1] [0.2 0.2 0.2] [0.3 0.3 0.3] [0.4 0.4 0.4] [0.8 0.8 0.8] [1.0 1.0 1.0]] Returns ------- :class:`bool` Is *LUT* domain explicit. Examples -------- >>> LUT3x1D().is_domain_explicit() False >>> samples = np.linspace(0, 1, 10) >>> table = domain = tstack([samples, samples, samples]) >>> LUT3x1D(table, domain=domain).is_domain_explicit() True """ return self.domain.shape != (2, 3)
[docs] @staticmethod def linear_table( size: ArrayLike | None = None, domain: ArrayLike | None = None, ) -> NDArrayFloat: """ Generate a linear table with the specified size and domain. The number of output samples :math:`n` is equal to ``size * 3`` or ``size[0] + size[1] + size[2]``. Parameters ---------- size Expected table size, default to 10. domain Domain of the table. Returns ------- :class:`numpy.ndarray` Linear table with ``size * 3`` or ``size[0] + size[1] + size[2]`` samples. Warnings -------- If ``size`` is non uniform, the linear table will be padded accordingly. Examples -------- >>> LUT3x1D.linear_table(5, np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]])) array([[-0.1, -0.2, -0.4], [ 0.3, 0.6, 1.2], [ 0.7, 1.4, 2.8], [ 1.1, 2.2, 4.4], [ 1.5, 3. , 6. ]]) >>> LUT3x1D.linear_table( ... np.array([5, 3, 2]), ... np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]), ... ) array([[-0.1, -0.2, -0.4], [ 0.3, 1.4, 6. ], [ 0.7, 3. , nan], [ 1.1, nan, nan], [ 1.5, nan, nan]]) >>> domain = np.array( ... [ ... [-0.1, -0.2, -0.4], ... [0.3, 1.4, 6.0], ... [0.7, 3.0, np.nan], ... [1.1, np.nan, np.nan], ... [1.5, np.nan, np.nan], ... ] ... ) >>> LUT3x1D.linear_table(domain=domain) array([[-0.1, -0.2, -0.4], [ 0.3, 1.4, 6. ], [ 0.7, 3. , nan], [ 1.1, nan, nan], [ 1.5, nan, nan]]) """ size = optional(size, 10) domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]])) if domain.shape != (2, 3): return domain size_array = np.tile(size, 3) if is_numeric(size) else as_int_array(size) R, G, B = tsplit(domain) samples = [ np.linspace(a[0], a[1], size_array[i]) for i, a in enumerate([R, G, B]) ] if len(np.unique(size_array)) != 1: runtime_warning( 'Table is non uniform, axis will be padded with "NaNs" accordingly!' ) samples = [ np.pad( axis, (0, np.max(size_array) - len(axis)), # pyright: ignore mode="constant", constant_values=np.nan, ) for axis in samples ] return tstack(samples)
[docs] def invert(self, **kwargs: Any) -> LUT3x1D: # noqa: ARG002 """ Compute and return an inverse copy of the *LUT*. Other Parameters ---------------- kwargs Keywords arguments, only specified for signature compatibility with the :meth:`AbstractLUT.invert` method. Returns ------- :class:`colour.LUT3x1D` Inverse *LUT* class instance. Examples -------- >>> LUT = LUT3x1D(LUT3x1D.linear_table() ** (1 / 2.2)) >>> print(LUT.table) [[0. 0. 0. ] [0.36834383 0.36834383 0.36834383] [0.50476034 0.50476034 0.50476034] [0.60691337 0.60691337 0.60691337] [0.69169882 0.69169882 0.69169882] [0.76553851 0.76553851 0.76553851] [0.83168433 0.83168433 0.83168433] [0.89204934 0.89204934 0.89204934] [0.94787016 0.94787016 0.94787016] [1. 1. 1. ]] >>> print(LUT.invert()) # doctest: +ELLIPSIS LUT3x1D - ... - Inverse ----------...---------- <BLANKLINE> Dimensions : 2 Domain : [[0. ... 0. ... 0. ...] [0.3683438... 0.3683438... 0.3683438...] [0.5047603... 0.5047603... 0.5047603...] [0.6069133... 0.6069133... 0.6069133...] [0.6916988... 0.6916988... 0.6916988...] [0.7655385... 0.7655385... 0.7655385...] [0.8316843... 0.8316843... 0.8316843...] [0.8920493... 0.8920493... 0.8920493...] [0.9478701... 0.9478701... 0.9478701...] [1. ... 1. ... 1. ...]] Size : (10, 3) >>> print(LUT.invert().table) # doctest: +ELLIPSIS [[0. ... 0. ... 0. ...] [0.1111111... 0.1111111... 0.1111111...] [0.2222222... 0.2222222... 0.2222222...] [0.3333333... 0.3333333... 0.3333333...] [0.4444444... 0.4444444... 0.4444444...] [0.5555555... 0.5555555... 0.5555555...] [0.6666666... 0.6666666... 0.6666666...] [0.7777777... 0.7777777... 0.7777777...] [0.8888888... 0.8888888... 0.8888888...] [1. ... 1. ... 1. ...]] """ size = self.table.size // 3 if self.is_domain_explicit(): domain = [ axes[: (~np.isnan(axes)).cumsum().argmax() + 1] for axes in np.transpose(self.domain) ] else: domain_min, domain_max = self.domain domain = [np.linspace(domain_min[i], domain_max[i], size) for i in range(3)] return LUT3x1D( table=tstack(domain), name=f"{self.name} - Inverse", domain=self.table, )
[docs] def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat: """ Apply the *LUT* to the specified *RGB* colourspace array using the specified method. Parameters ---------- RGB *RGB* colourspace array to apply the *LUT* onto. Other Parameters ---------------- direction Whether the *LUT* should be applied in the forward or inverse direction. extrapolator Extrapolator class type or object to use as extrapolating function. extrapolator_kwargs Arguments to use when instantiating or calling the extrapolating function. interpolator Interpolator class type to use as interpolating function. interpolator_kwargs Arguments to use when instantiating the interpolating function. Returns ------- :class:`numpy.ndarray` Interpolated *RGB* colourspace array. Examples -------- >>> LUT = LUT3x1D(LUT3x1D.linear_table() ** (1 / 2.2)) >>> RGB = np.array([0.18, 0.18, 0.18]) >>> LUT.apply(RGB) # doctest: +ELLIPSIS array([0.4529220..., 0.4529220..., 0.4529220...]) >>> LUT.apply(LUT.apply(RGB), direction="Inverse") ... # doctest: +ELLIPSIS array([0.18..., 0.18..., 0.18...]) >>> from colour.algebra import spow >>> domain = np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]) >>> table = spow(LUT3x1D.linear_table(domain=domain), 1 / 2.2) >>> LUT = LUT3x1D(table, domain=domain) >>> RGB = np.array([0.18, 0.18, 0.18]) >>> LUT.apply(RGB) # doctest: +ELLIPSIS array([0.4423903..., 0.4503801..., 0.3581625...]) >>> domain = np.array( ... [ ... [-0.1, -0.2, -0.4], ... [0.3, 1.4, 6.0], ... [0.7, 3.0, np.nan], ... [1.1, np.nan, np.nan], ... [1.5, np.nan, np.nan], ... ] ... ) >>> table = spow(LUT3x1D.linear_table(domain=domain), 1 / 2.2) >>> LUT = LUT3x1D(table, domain=domain) >>> RGB = np.array([0.18, 0.18, 0.18]) >>> LUT.apply(RGB) # doctest: +ELLIPSIS array([ 0.2996370..., -0.0901332..., -0.3949770...]) """ direction = validate_method( kwargs.get("direction", "Forward"), ("Forward", "Inverse") ) interpolator = kwargs.get("interpolator", LinearInterpolator) interpolator_kwargs = kwargs.get("interpolator_kwargs", {}) extrapolator = kwargs.get("extrapolator", Extrapolator) extrapolator_kwargs = kwargs.get("extrapolator_kwargs", {}) R, G, B = tsplit(RGB) LUT = self.invert() if direction == "inverse" else self size = LUT.table.size // 3 if LUT.is_domain_explicit(): samples = [ axes[: (~np.isnan(axes)).cumsum().argmax() + 1] for axes in np.transpose(LUT.domain) ] R_t, G_t, B_t = ( axes[: len(samples[i])] for i, axes in enumerate(np.transpose(LUT.table)) ) else: domain_min, domain_max = LUT.domain samples = [ np.linspace(domain_min[i], domain_max[i], size) for i in range(3) ] R_t, G_t, B_t = tsplit(LUT.table) s_R, s_G, s_B = samples RGB_i = [ extrapolator( interpolator(a[0], a[1], **interpolator_kwargs), **extrapolator_kwargs, )(a[2]) for a in zip((s_R, s_G, s_B), (R_t, G_t, B_t), (R, G, B), strict=True) ] return tstack(RGB_i)
[docs] class LUT3D(AbstractLUT): """ Define the base class for a 3-dimensional lookup table (3D *LUT*). This class provides a foundation for working with 3D lookup tables, which map input colour values through a discretized 3D grid to output colour values. The table operates on three input channels simultaneously, making it suitable for RGB-to-RGB colour transformations and other tristimulus colour space operations. Parameters ---------- table Underlying *LUT* table. name *LUT* name. domain *LUT* domain, also used to define the instantiation time default table domain. size Size of the instantiation time default table, default to 33. comments Comments to add to the *LUT*. Methods ------- - :meth:`~colour.LUT3D.__init__` - :meth:`~colour.LUT3D.is_domain_explicit` - :meth:`~colour.LUT3D.linear_table` - :meth:`~colour.LUT3D.invert` - :meth:`~colour.LUT3D.apply` Examples -------- Instantiating a unity LUT with a table with 16x16x16x3 elements: >>> print(LUT3D(size=16)) LUT3D - Unity 16 ---------------- <BLANKLINE> Dimensions : 3 Domain : [[0. 0. 0.] [1. 1. 1.]] Size : (16, 16, 16, 3) Instantiating a LUT using a custom table with 16x16x16x3 elements: >>> print(LUT3D(LUT3D.linear_table(16) ** (1 / 2.2))) # doctest: +ELLIPSIS LUT3D - ... --------... <BLANKLINE> Dimensions : 3 Domain : [[0. 0. 0.] [1. 1. 1.]] Size : (16, 16, 16, 3) Instantiating a LUT using a custom table with 16x16x16x3 elements, custom name, custom domain and comments: >>> from colour.algebra import spow >>> domain = np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]) >>> print( ... LUT3D( ... spow(LUT3D.linear_table(16), 1 / 2.2), ... "My LUT", ... domain, ... comments=["A first comment.", "A second comment."], ... ) ... ) LUT3D - My LUT -------------- <BLANKLINE> Dimensions : 3 Domain : [[-0.1 -0.2 -0.4] [ 1.5 3. 6. ]] Size : (16, 16, 16, 3) Comment 01 : A first comment. Comment 02 : A second comment. """
[docs] def __init__( self, table: ArrayLike | None = None, name: str | None = None, domain: ArrayLike | None = None, size: ArrayLike | None = None, comments: Sequence | None = None, ) -> None: domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]])) size = optional(size, 33) super().__init__(table, name, 3, domain, size, comments)
def _validate_table(self, table: ArrayLike) -> NDArrayFloat: """ Validate that the specified table is a 4D array with equal dimensions. Parameters ---------- table Table to validate. Returns ------- :class:`numpy.ndarray` Validated table as a :class:`numpy.ndarray` instance. """ table = as_float_array(table) attest(len(table.shape) == 4, "The table must be a 4D array!") return table def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat: """ Validate the specified domain for the 3D lookup table. Parameters ---------- domain Domain array to validate. Must be a 2D array with at least 2 rows and exactly 3 columns. Returns ------- :class:`numpy.ndarray` Validated domain as a :class:`numpy.ndarray` instance. Notes ----- - A :class:`LUT3D` class instance must use an implicit domain. """ domain = as_float_array(domain) attest(len(domain.shape) == 2, "The domain must be a 2D array!") attest( domain.shape[0] >= 2, "The domain row count must be equal or greater than 2!", ) attest(domain.shape[1] == 3, "The domain column count must be equal to 3!") return domain
[docs] def is_domain_explicit(self) -> bool: """ Return whether the *LUT* domain is explicit (or implicit). An implicit domain is defined by its shape only:: [[0 0 0] [1 1 1]] While an explicit domain defines every single discrete sample:: [[0.0 0.0 0.0] [0.1 0.1 0.1] [0.2 0.2 0.2] [0.3 0.3 0.3] [0.4 0.4 0.4] [0.8 0.8 0.8] [1.0 1.0 1.0]] Returns ------- :class:`bool` Is *LUT* domain explicit. Examples -------- >>> LUT3D().is_domain_explicit() False >>> domain = np.array([[-0.1, -0.2, -0.4], [0.7, 1.4, 6.0], [1.5, 3.0, np.nan]]) >>> LUT3D(domain=domain).is_domain_explicit() True """ return self.domain.shape != (2, 3)
[docs] @staticmethod def linear_table( size: ArrayLike | None = None, domain: ArrayLike | None = None, ) -> NDArrayFloat: """ Generate a linear table with the specified size and domain. The number of output samples :math:`n` is equal to ``size**3 * 3`` or ``size[0] * size[1] * size[2] * 3``. Parameters ---------- size Expected table size, default to 33. domain Domain of the table. Returns ------- :class:`numpy.ndarray` Linear table with ``size**3 * 3`` or ``size[0] * size[1] * size[2] * 3`` samples. Examples -------- >>> LUT3D.linear_table(3, np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]])) array([[[[-0.1, -0.2, -0.4], [-0.1, -0.2, 2.8], [-0.1, -0.2, 6. ]], <BLANKLINE> [[-0.1, 1.4, -0.4], [-0.1, 1.4, 2.8], [-0.1, 1.4, 6. ]], <BLANKLINE> [[-0.1, 3. , -0.4], [-0.1, 3. , 2.8], [-0.1, 3. , 6. ]]], <BLANKLINE> <BLANKLINE> [[[ 0.7, -0.2, -0.4], [ 0.7, -0.2, 2.8], [ 0.7, -0.2, 6. ]], <BLANKLINE> [[ 0.7, 1.4, -0.4], [ 0.7, 1.4, 2.8], [ 0.7, 1.4, 6. ]], <BLANKLINE> [[ 0.7, 3. , -0.4], [ 0.7, 3. , 2.8], [ 0.7, 3. , 6. ]]], <BLANKLINE> <BLANKLINE> [[[ 1.5, -0.2, -0.4], [ 1.5, -0.2, 2.8], [ 1.5, -0.2, 6. ]], <BLANKLINE> [[ 1.5, 1.4, -0.4], [ 1.5, 1.4, 2.8], [ 1.5, 1.4, 6. ]], <BLANKLINE> [[ 1.5, 3. , -0.4], [ 1.5, 3. , 2.8], [ 1.5, 3. , 6. ]]]]) >>> LUT3D.linear_table( ... np.array([3, 3, 2]), ... np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]), ... ) array([[[[-0.1, -0.2, -0.4], [-0.1, -0.2, 6. ]], <BLANKLINE> [[-0.1, 1.4, -0.4], [-0.1, 1.4, 6. ]], <BLANKLINE> [[-0.1, 3. , -0.4], [-0.1, 3. , 6. ]]], <BLANKLINE> <BLANKLINE> [[[ 0.7, -0.2, -0.4], [ 0.7, -0.2, 6. ]], <BLANKLINE> [[ 0.7, 1.4, -0.4], [ 0.7, 1.4, 6. ]], <BLANKLINE> [[ 0.7, 3. , -0.4], [ 0.7, 3. , 6. ]]], <BLANKLINE> <BLANKLINE> [[[ 1.5, -0.2, -0.4], [ 1.5, -0.2, 6. ]], <BLANKLINE> [[ 1.5, 1.4, -0.4], [ 1.5, 1.4, 6. ]], <BLANKLINE> [[ 1.5, 3. , -0.4], [ 1.5, 3. , 6. ]]]]) >>> domain = np.array([[-0.1, -0.2, -0.4], [0.7, 1.4, 6.0], [1.5, 3.0, np.nan]]) >>> LUT3D.linear_table(domain=domain) array([[[[-0.1, -0.2, -0.4], [-0.1, -0.2, 6. ]], <BLANKLINE> [[-0.1, 1.4, -0.4], [-0.1, 1.4, 6. ]], <BLANKLINE> [[-0.1, 3. , -0.4], [-0.1, 3. , 6. ]]], <BLANKLINE> <BLANKLINE> [[[ 0.7, -0.2, -0.4], [ 0.7, -0.2, 6. ]], <BLANKLINE> [[ 0.7, 1.4, -0.4], [ 0.7, 1.4, 6. ]], <BLANKLINE> [[ 0.7, 3. , -0.4], [ 0.7, 3. , 6. ]]], <BLANKLINE> <BLANKLINE> [[[ 1.5, -0.2, -0.4], [ 1.5, -0.2, 6. ]], <BLANKLINE> [[ 1.5, 1.4, -0.4], [ 1.5, 1.4, 6. ]], <BLANKLINE> [[ 1.5, 3. , -0.4], [ 1.5, 3. , 6. ]]]]) """ size = optional(size, 33) domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]])) if domain.shape != (2, 3): samples = list( np.flip( # NOTE: "dtype=object" is required for ragged array support # in "Numpy" 1.24.0. as_array( [ axes[: (~np.isnan(axes)).cumsum().argmax() + 1] for axes in np.transpose(domain) ], dtype=object, # pyright: ignore ), -1, ) ) size_array = as_int_array([len(axes) for axes in samples]) else: size_array = np.tile(size, 3) if is_numeric(size) else as_int_array(size) R, G, B = tsplit(domain) size_array = np.flip(size_array, -1) samples = [ np.linspace(a[0], a[1], size_array[i]) for i, a in enumerate([B, G, R]) ] return np.flip( np.reshape( np.transpose(np.meshgrid(*samples, indexing="ij")), np.hstack([np.flip(size_array, -1), 3]), ), -1, )
[docs] @required("SciPy") def invert(self, **kwargs: Any) -> LUT3D: """ Compute and return an inverse copy of the *LUT*. Other Parameters ---------------- interpolator Interpolator class type or object to use as interpolating function. query_size Number of nearest neighbors to use for Shepard interpolation (inverse distance weighting). Default is 8, optimized for speed and quality. Higher values (16-32) may slightly improve smoothness but significantly increase computation time. gamma Gradient smoothness parameter for Shepard interpolation. Default is 3.0 (optimized for smoothness). Controls the weight falloff rate in inverse distance weighting (:math:`w_i = 1/d_i^{1/gamma}`). Higher gamma values produce smoother gradients. - Default (3.0): Optimal smoothness with minimal artifacts - Lower values (1.5-2.0): Sharper transitions, faster computation, may increase banding artifacts - Very low values (0.5-1.0): Maximum sharpness, more localized interpolation, higher banding risk sigma Gaussian blur sigma for iterative adaptive smoothing. Default is 0.7. Smoothing is applied iteratively only to high-gradient regions (banding artifacts) identified using the percentile threshold, preserving quality in smooth regions. - Default (0.7): Optimal smoothing - reduces banding by ~38% (26 → 16 artifacts) while preserving corners - Higher values (0.8-0.9): More aggressive, may increase corner shift - Lower values (0.5-0.6): Gentler smoothing, better corner preservation - Set to 0.0 to disable adaptive smoothing entirely The iterative adaptive approach with gradient recomputation ensures clean LUTs remain unaffected while problematic regions receive targeted smoothing. tau Percentile threshold for identifying high-gradient regions (0-1). Default is 0.75 (75th percentile). Higher values mean fewer regions are smoothed (more selective), lower values mean more regions are smoothed (more aggressive). - Default (0.75): Smooths top 25% of gradient regions - Higher values (0.85-0.95): Very selective, minimal smoothing - Lower values (0.50-0.65): More aggressive, smooths more regions Only used when sigma > 0. iterations Number of iterative smoothing passes. Default is 10. Each iteration recomputes gradients and adapts smoothing to the evolving LUT state, providing better artifact reduction than a single strong blur. - Default (10): Optimal balance of quality and performance - Higher values (12-15): Slightly better artifact reduction, slower - Lower values (5-7): Faster, but fewer artifacts removed Only used when sigma > 0. oversampling Oversampling factor for building the KDTree. Default is 1.2. The optimal value is based on Jacobian analysis of the LUT transformation: the Jacobian matrix :math:`J = \\partial(output)/\\partial(input)` measures local volume distortion. When :math:`|J| < 1`, the LUT compresses space, requiring higher sampling density for accurate inversion. The factor 1.2 captures approximately 80% of the theoretical accuracy benefit at 30% of the computational cost. Values between 1.0 (no oversampling) and 2.0 (diminishing returns) are supported. size Size of the inverse *LUT*. With the specified implementation, it is good practise to double the size of the inverse *LUT* to provide a smoother result. If ``size`` is not specified, :math:`2^{\\sqrt{size_{LUT}} + 1} + 1` will be used instead. Returns ------- :class:`colour.LUT3D` Inverse *LUT* class instance. Examples -------- >>> LUT = LUT3D() >>> print(LUT) LUT3D - Unity 33 ---------------- <BLANKLINE> Dimensions : 3 Domain : [[0. 0. 0.] [1. 1. 1.]] Size : (33, 33, 33, 3) >>> print(LUT.invert()) LUT3D - Unity 33 - Inverse -------------------------- <BLANKLINE> Dimensions : 3 Domain : [[0. 0. 0.] [1. 1. 1.]] Size : (108, 108, 108, 3) """ from scipy.ndimage import gaussian_filter # noqa: PLC0415 from scipy.spatial import KDTree # noqa: PLC0415 if self.is_domain_explicit(): error = 'Inverting a "LUT3D" with an explicit domain is not implemented!' raise NotImplementedError(error) interpolator = kwargs.get("interpolator", table_interpolation_trilinear) query_size = kwargs.get("query_size", 8) gamma = kwargs.get("gamma", 3.0) sigma = kwargs.get("sigma", 0.7) tau = kwargs.get("tau", 0.75) oversampling = kwargs.get("oversampling", 1.2) LUT = self.copy() source_size = LUT.size target_size = kwargs.get("size", (as_int(2 ** (np.sqrt(source_size) + 1) + 1))) sampling_size = int(target_size * oversampling) if target_size > 129: # pragma: no cover usage_warning("LUT3D inverse computation time could be excessive!") # "LUT_t" is an intermediate LUT with oversampling to better capture # the LUT's transformation, especially in regions with high compression. # Sampling factor of 1.2 is based on Jacobian analysis: captures 80% # of theoretical benefit at 30% of computational cost. LUT_t = LUT3D(size=sampling_size, domain=LUT.domain) table = np.reshape(LUT_t.table, (-1, 3)) LUT_t.table = LUT.apply(LUT_t.table, interpolator=interpolator) tree = KDTree(np.reshape(LUT_t.table, (-1, 3))) # "LUT_q" stores the inverse LUT with improved interpolation. # Query at the target resolution (output size). LUT_q = LUT3D(size=target_size, domain=LUT.domain) query_points = np.reshape(LUT_q.table, (-1, 3)) distances, indices = tree.query(query_points, query_size) if query_size == 1: # Single nearest neighbor - no interpolation needed LUT_q.table = np.reshape( table[indices], (target_size, target_size, target_size, 3) ) else: # Shepard's method (inverse distance weighting) for smooth interpolation. # Uses w_i = 1 / d_i^(1/gamma) where gamma controls the falloff rate. # Higher gamma (e.g., 2.0-4.0) creates smoother gradients by blending more # globally, while lower gamma (e.g., 0.25-0.5) creates sharper transitions. power = 1.0 / gamma distances = cast("NDArrayFloat", distances) weights = 1.0 / (distances + EPSILON) ** power weights = weights / np.sum(weights, axis=1, keepdims=True) # Weighted average: sum over neighbors dimension weighted_table = np.sum(table[indices] * weights[..., np.newaxis], axis=1) LUT_q.table = np.reshape( weighted_table, (target_size, target_size, target_size, 3), ) # Apply iterative adaptive smoothing based on gradient magnitude. # Smooths only high-gradient regions (banding artifacts) while preserving # quality in smooth regions. Multiple iterations with gradient recomputation # allow smoothing to adapt as the LUT evolves. if sigma > 0: def extrapolate(data_3d: NDArrayFloat, pad_width: int) -> NDArrayFloat: """ Pad the 3D array with linear extrapolation based on edge gradients. For each axis, extrapolate using: value[edge + i] = value[edge] + i * gradient This preserves boundary values much better than reflect/mirror modes. """ result = data_3d for axis in range(3): # Compute edge gradients edge_lo = np.take(result, [0], axis=axis) edge_hi = np.take(result, [-1], axis=axis) grad_lo = edge_lo - np.take(result, [1], axis=axis) grad_hi = edge_hi - np.take(result, [-2], axis=axis) # Create padding using linear extrapolation pad_lo = [edge_lo + (i + 1) * grad_lo for i in range(pad_width)] pad_hi = [edge_hi + (i + 1) * grad_hi for i in range(pad_width)] # Concatenate (reverse low padding) result = np.concatenate([*pad_lo[::-1], result, *pad_hi], axis=axis) return result # Iterative smoothing: apply multiple passes with gradient recomputation. # Each iteration adapts to the evolving LUT state, providing better # artifact reduction than a single strong blur. iterations = kwargs.get("iterations", 10) pad_width = 10 for _ in range(iterations): # Recompute gradient magnitude at each iteration to adapt # to the current LUT state gradient_magnitude = np.zeros(LUT_q.table.shape[:3]) for i in range(3): gx = np.gradient(LUT_q.table[..., i], axis=0) gy = np.gradient(LUT_q.table[..., i], axis=1) gz = np.gradient(LUT_q.table[..., i], axis=2) gradient_magnitude = gradient_magnitude + np.sqrt( gx**2 + gy**2 + gz**2 ) gradient_magnitude = gradient_magnitude / 3.0 # Identify high-gradient regions using percentile threshold threshold = np.percentile(gradient_magnitude, tau * 100) # Apply Gaussian blur with linear extrapolation padding for i in range(3): # Pad with linear extrapolation (recomputed each iteration) table_p = extrapolate(LUT_q.table[..., i], pad_width) # Filter the padded data table_f = gaussian_filter(table_p, sigma=sigma) # Un-pad table_e = table_f[ pad_width:-pad_width, pad_width:-pad_width, pad_width:-pad_width, ] # Apply selectively to high-gradient regions only LUT_q.table[..., i] = np.where( gradient_magnitude > threshold, table_e, LUT_q.table[..., i], ) LUT_q.name = f"{self.name} - Inverse" return LUT_q
[docs] def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat: """ Apply the *LUT* to the specified *RGB* colourspace array using the specified interpolation method. Parameters ---------- RGB *RGB* colourspace array to apply the *LUT* onto. Other Parameters ---------------- direction Whether the *LUT* should be applied in the forward or inverse direction. interpolator Interpolator object to use as the interpolating function. interpolator_kwargs Arguments to use when calling the interpolating function. query_size Number of points to query in the KDTree, with their mean computed to produce a smoother result. size Size of the inverse *LUT*. With the specified implementation, it is recommended to double the size of the inverse *LUT* to provide a smoother result. If ``size`` is not specified, :math:`2^{\\sqrt{size_{LUT}} + 1} + 1` will be used instead. Returns ------- :class:`numpy.ndarray` Interpolated *RGB* colourspace array. Examples -------- >>> LUT = LUT3D(LUT3D.linear_table() ** (1 / 2.2)) >>> RGB = np.array([0.18, 0.18, 0.18]) >>> LUT.apply(RGB) # doctest: +ELLIPSIS array([0.4583277..., 0.4583277..., 0.4583277...]) >>> LUT.apply(LUT.apply(RGB), direction="Inverse") ... # doctest: +ELLIPSIS +SKIP array([0.1799897..., 0.1796077..., 0.1795868...]) >>> from colour.algebra import spow >>> domain = np.array( ... [ ... [-0.1, -0.2, -0.4], ... [0.3, 1.4, 6.0], ... [0.7, 3.0, np.nan], ... [1.1, np.nan, np.nan], ... [1.5, np.nan, np.nan], ... ] ... ) >>> table = spow(LUT3D.linear_table(domain=domain), 1 / 2.2) >>> LUT = LUT3D(table, domain=domain) >>> RGB = np.array([0.18, 0.18, 0.18]) >>> LUT.apply(RGB) # doctest: +ELLIPSIS array([ 0.2996370..., -0.0901332..., -0.3949770...]) """ direction = validate_method( kwargs.get("direction", "Forward"), ("Forward", "Inverse") ) interpolator = kwargs.get("interpolator", table_interpolation_trilinear) interpolator_kwargs = kwargs.get("interpolator_kwargs", {}) R, G, B = tsplit(RGB) settings = {"interpolator": interpolator} settings.update(**kwargs) LUT = self.invert(**settings) if direction == "inverse" else self if LUT.is_domain_explicit(): domain_min = LUT.domain[0, ...] domain_max = [ axes[: (~np.isnan(axes)).cumsum().argmax() + 1][-1] for axes in np.transpose(LUT.domain) ] usage_warning( f'"LUT" was defined with an explicit domain but requires an ' f"implicit domain to be applied. The following domain will be " f"used: {np.vstack([domain_min, domain_max])}" ) else: domain_min, domain_max = LUT.domain RGB_l = [ linear_conversion(j, (domain_min[i], domain_max[i]), (0, 1)) for i, j in enumerate((R, G, B)) ] return interpolator(tstack(RGB_l), LUT.table, **interpolator_kwargs)
[docs] def LUT_to_LUT( LUT: AbstractLUT, cls: Type[AbstractLUT], force_conversion: bool = False, **kwargs: Any, ) -> AbstractLUT: """ Convert a specified *LUT* to the specified ``cls`` class instance. This function facilitates conversion between different LUT class types, including LUT1D, LUT3x1D, and LUT3D instances. Some conversions may be destructive and require explicit force conversion. Parameters ---------- LUT *LUT* to convert. cls Target *LUT* class type for conversion. force_conversion Whether to force the conversion if it would be destructive. Other Parameters ---------------- channel_weights Channel weights in case of a downcast from a :class:`LUT3x1D` or :class:`LUT3D` class instance. interpolator Interpolator class type to use as interpolating function. interpolator_kwargs Arguments to use when instantiating the interpolating function. size Expected table size in case of an upcast to or a downcast from a :class:`LUT3D` class instance. Returns ------- :class:`colour.LUT1D` or :class:`colour.LUT3x1D` or :class:`colour.LUT3D` Converted *LUT* class instance. Warnings -------- Some conversions are destructive and raise a :class:`ValueError` exception by default. Raises ------ ValueError If the conversion is destructive. Examples -------- >>> print(LUT_to_LUT(LUT1D(), LUT3D, force_conversion=True)) LUT3D - Unity 10 - Converted 1D to 3D ------------------------------------- <BLANKLINE> Dimensions : 3 Domain : [[0. 0. 0.] [1. 1. 1.]] Size : (33, 33, 33, 3) >>> print(LUT_to_LUT(LUT3x1D(), LUT1D, force_conversion=True)) LUT1D - Unity 10 - Converted 3x1D to 1D --------------------------------------- <BLANKLINE> Dimensions : 1 Domain : [0. 1.] Size : (10,) >>> print(LUT_to_LUT(LUT3D(), LUT1D, force_conversion=True)) LUT1D - Unity 33 - Converted 3D to 1D ------------------------------------- <BLANKLINE> Dimensions : 1 Domain : [0. 1.] Size : (10,) """ ranks = {LUT1D: 1, LUT3x1D: 2, LUT3D: 3} path = (ranks[LUT.__class__], ranks[cls]) path_verbose = [f"{element}D" if element != 2 else "3x1D" for element in path] if path in ((1, 3), (2, 1), (2, 3), (3, 1), (3, 2)) and not force_conversion: error = ( f'Conversion of a "LUT" {path_verbose[0]} to a "LUT" ' f"{path_verbose[1]} is destructive, please use the " f'"force_conversion" argument to proceed!' ) raise ValueError(error) suffix = f" - Converted {path_verbose[0]} to {path_verbose[1]}" name = f"{LUT.name}{suffix}" # Same dimension conversion, returning a copy. if len(set(path)) == 1: LUT = LUT.copy() LUT.name = name else: size = kwargs.get("size", 33 if cls is LUT3D else 10) kwargs.pop("size", None) channel_weights = as_float_array(kwargs.get("channel_weights", full(3, 1 / 3))) kwargs.pop("channel_weights", None) if isinstance(LUT, LUT1D): if cls is LUT3x1D: domain = tstack([LUT.domain, LUT.domain, LUT.domain]) table = tstack([LUT.table, LUT.table, LUT.table]) elif cls is LUT3D: domain = tstack([LUT.domain, LUT.domain, LUT.domain]) table = LUT3D.linear_table(size, domain) table = LUT.apply(table, **kwargs) elif isinstance(LUT, LUT3x1D): if cls is LUT1D: domain = np.sum(LUT.domain * channel_weights, axis=-1) table = np.sum(LUT.table * channel_weights, axis=-1) elif cls is LUT3D: domain = LUT.domain table = LUT3D.linear_table(size, domain) table = LUT.apply(table, **kwargs) elif isinstance(LUT, LUT3D): if cls is LUT1D: domain = np.sum(LUT.domain * channel_weights, axis=-1) table = LUT1D.linear_table(size, domain) table = LUT.apply(tstack([table, table, table]), **kwargs) table = np.sum(table * channel_weights, axis=-1) elif cls is LUT3x1D: domain = LUT.domain table = LUT3x1D.linear_table(size, domain) table = LUT.apply(table, **kwargs) LUT = cls( table=table, name=name, domain=domain, size=table.shape[0], comments=LUT.comments, ) return LUT