"""
Interpolation
=============
Provide classes and functions for interpolating variables in colour science
computations.
This module implements various interpolation methods for one-dimensional
functions and multi-dimensional table-based interpolation. These methods
support spectral data processing, colour transformations, and general
numerical interpolation tasks in colour science applications.
- :class:`colour.KernelInterpolator`: 1-D function generic interpolation
with arbitrary kernel.
- :class:`colour.NearestNeighbourInterpolator`: 1-D function
nearest-neighbour interpolation.
- :class:`colour.LinearInterpolator`: 1-D function linear interpolation.
- :class:`colour.SpragueInterpolator`: 1-D function fifth-order polynomial
interpolation using *Sprague (1880)* method.
- :class:`colour.CubicSplineInterpolator`: 1-D function cubic spline
interpolation.
- :class:`colour.PchipInterpolator`: 1-D function piecewise cube Hermite
interpolation.
- :class:`colour.NullInterpolator`: 1-D function null interpolation.
- :func:`colour.lagrange_coefficients`: Compute *Lagrange Coefficients*.
- :func:`colour.algebra.table_interpolation_trilinear`: Perform trilinear
interpolation with table.
- :func:`colour.algebra.table_interpolation_tetrahedral`: Perform
tetrahedral interpolation with table.
- :attr:`colour.TABLE_INTERPOLATION_METHODS`: Supported table interpolation
methods.
- :func:`colour.table_interpolation`: Perform interpolation with table using
specified method.
References
----------
- :cite:`Bourkeb` : Bourke, P. (n.d.). Trilinear Interpolation. Retrieved
January 13, 2018, from http://paulbourke.net/miscellaneous/interpolation/
- :cite:`Burger2009b` : Burger, W., & Burge, M. J. (2009). Principles of
Digital Image Processing. Springer London. doi:10.1007/978-1-84800-195-4
- :cite:`CIETC1-382005f` : CIE TC 1-38. (2005). 9.2.4 Method of
interpolation for uniformly spaced independent variable. In CIE 167:2005
Recommended Practice for Tabulating Spectral Data for Use in Colour
Computations (pp. 1-27). ISBN:978-3-901906-41-1
- :cite:`CIETC1-382005h` : CIE TC 1-38. (2005). Table V. Values of the
c-coefficients of Equ.s 6 and 7. In CIE 167:2005 Recommended Practice for
Tabulating Spectral Data for Use in Colour Computations (p. 19).
ISBN:978-3-901906-41-1
- :cite:`Fairman1985b` : Fairman, H. S. (1985). The calculation of weight
factors for tristimulus integration. Color Research & Application, 10(4),
199-203. doi:10.1002/col.5080100407
- :cite:`Kirk2006` : Kirk, R. (2006). Truelight Software Library 2.0.
Retrieved July 8, 2017, from
https://www.filmlight.ltd.uk/pdf/whitepapers/FL-TL-TN-0057-SoftwareLib.pdf
- :cite:`Westland2012h` : Westland, S., Ripamonti, C., & Cheung, V. (2012).
Interpolation Methods. In Computational Colour Science Using MATLAB (2nd
ed., pp. 29-37). ISBN:978-0-470-66569-5
- :cite:`Wikipedia2003a` : Wikipedia. (2003). Lagrange polynomial -
Definition. Retrieved January 20, 2016, from
https://en.wikipedia.org/wiki/Lagrange_polynomial#Definition
- :cite:`Wikipedia2005b` : Wikipedia. (2005). Lanczos resampling. Retrieved
October 14, 2017, from https://en.wikipedia.org/wiki/Lanczos_resampling
"""
from __future__ import annotations
import sys
import typing
from functools import reduce
from unittest.mock import MagicMock
import numpy as np
from colour.utilities.requirements import is_scipy_installed
from colour.utilities.verbose import usage_warning
if not is_scipy_installed(): # pragma: no cover
try:
is_scipy_installed(raise_exception=True)
except ImportError as error:
usage_warning(str(error))
mock = MagicMock()
mock.__name__ = ""
for module in (
"scipy",
"scipy.interpolate",
):
sys.modules[module] = mock
import scipy.interpolate
from colour.algebra import sdiv, sdiv_mode
from colour.constants import (
DTYPE_FLOAT_DEFAULT,
DTYPE_INT_DEFAULT,
TOLERANCE_ABSOLUTE_DEFAULT,
TOLERANCE_RELATIVE_DEFAULT,
)
if typing.TYPE_CHECKING:
from colour.hints import (
Any,
ArrayLike,
Callable,
DTypeReal,
Literal,
Type,
)
from colour.hints import NDArrayFloat, cast
from colour.utilities import (
CanonicalMapping,
as_array,
as_float,
as_float_array,
as_float_scalar,
as_int_array,
attest,
closest_indexes,
interval,
is_numeric,
optional,
runtime_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__ = [
"kernel_nearest_neighbour",
"kernel_linear",
"kernel_sinc",
"kernel_lanczos",
"kernel_cardinal_spline",
"KernelInterpolator",
"NearestNeighbourInterpolator",
"LinearInterpolator",
"SpragueInterpolator",
"CubicSplineInterpolator",
"PchipInterpolator",
"NullInterpolator",
"lagrange_coefficients",
"table_interpolation_trilinear",
"table_interpolation_tetrahedral",
"TABLE_INTERPOLATION_METHODS",
"table_interpolation",
]
[docs]
def kernel_nearest_neighbour(x: ArrayLike) -> NDArrayFloat:
"""
Return the *nearest-neighbour* kernel evaluated at specified samples.
The *nearest-neighbour* kernel is a discontinuous kernel function that
equals 1 for samples within the range [-0.5, 0.5) and 0 elsewhere. This
kernel represents the simplest interpolation method where each output
value is determined by the closest input sample.
Parameters
----------
x
Samples at which to evaluate the *nearest-neighbour* kernel.
Returns
-------
:class:`numpy.ndarray`
The *nearest-neighbour* kernel evaluated at specified samples.
References
----------
:cite:`Burger2009b`
Examples
--------
>>> kernel_nearest_neighbour(np.linspace(0, 1, 10))
array([1, 1, 1, 1, 1, 0, 0, 0, 0, 0])
"""
return np.where(np.abs(x) < 0.5, 1, 0)
[docs]
def kernel_linear(x: ArrayLike) -> NDArrayFloat:
"""
Evaluate the *linear* kernel at specified samples.
The *linear* kernel is a triangular function that returns 1 when
:math:`|x| < 0.5` and 0 otherwise, providing a simple binary response
based on the absolute value of the input.
Parameters
----------
x
Samples at which to evaluate the *linear* kernel.
Returns
-------
:class:`numpy.ndarray`
The *linear* kernel evaluated at specified samples, with values of 1
for :math:`|x| < 0.5` and 0 otherwise.
References
----------
:cite:`Burger2009b`
Examples
--------
>>> kernel_linear(np.linspace(0, 1, 10)) # doctest: +ELLIPSIS
array([ 1. , 0.8888888..., 0.7777777..., \
0.6666666..., 0.5555555...,
0.4444444..., 0.3333333..., 0.2222222..., \
0.1111111..., 0. ])
"""
return np.where(np.abs(x) < 1, 1 - np.abs(x), 0)
[docs]
def kernel_sinc(x: ArrayLike, a: float = 3) -> NDArrayFloat:
"""
Evaluate the *sinc* kernel at specified sample positions.
Compute the *sinc* kernel function, commonly used in signal processing
and interpolation applications, for the specified sample positions.
Parameters
----------
x
Sample positions at which to evaluate the *sinc* kernel.
a
Size parameter of the *sinc* kernel, controlling the function's
support width.
Returns
-------
:class:`numpy.ndarray`
*Sinc* kernel values evaluated at the specified sample positions.
Raises
------
AssertionError
If ``a`` is less than 1.
References
----------
:cite:`Burger2009b`
Examples
--------
>>> kernel_sinc(np.linspace(0, 1, 10)) # doctest: +ELLIPSIS
array([ 1.0000000...e+00, 9.7981553...e-01, 9.2072542...e-01,
8.2699334...e-01, 7.0531659...e-01, 5.6425327...e-01,
4.1349667...e-01, 2.6306440...e-01, 1.2247694...e-01,
3.8981718...e-17])
"""
x = as_float_array(x)
attest(bool(a >= 1), '"a" must be equal or superior to 1!')
return np.where(np.abs(x) < a, np.sinc(x), 0)
[docs]
def kernel_lanczos(x: ArrayLike, a: float = 3) -> NDArrayFloat:
"""
Return the *Lanczos* kernel evaluated at specified samples.
The *Lanczos* kernel is a sinc-based windowing function commonly used
in signal processing and image resampling applications. It is defined
as :math:`L(x) = \\text{sinc}(x) \\cdot \\text{sinc}(x/a)` for
:math:`|x| < a`, and zero otherwise.
Parameters
----------
x
Samples at which to evaluate the *Lanczos* kernel.
a
Size of the *Lanczos* kernel, defining the support region
:math:`[-a, a]`.
Returns
-------
:class:`numpy.ndarray`
The *Lanczos* kernel evaluated at specified samples.
References
----------
:cite:`Wikipedia2005b`
Examples
--------
>>> kernel_lanczos(np.linspace(0, 1, 10)) # doctest: +ELLIPSIS
array([ 1.0000000...e+00, 9.7760615...e-01, 9.1243770...e-01,
8.1030092...e-01, 6.8012706...e-01, 5.3295773...e-01,
3.8071690...e-01, 2.3492839...e-01, 1.0554054...e-01,
3.2237621...e-17])
"""
x = as_float_array(x)
attest(bool(a >= 1), '"a" must be equal or superior to 1!')
return np.where(np.abs(x) < a, np.sinc(x) * np.sinc(x / a), 0)
[docs]
def kernel_cardinal_spline(
x: ArrayLike, a: float = 0.5, b: float = 0.0
) -> NDArrayFloat:
"""
Return the *cardinal spline* kernel evaluated at specified samples.
Notable *cardinal spline* :math:`a` and :math:`b` parameterizations:
- *Catmull-Rom*: :math:`(a=0.5, b=0)`
- *Cubic B-Spline*: :math:`(a=0, b=1)`
- *Mitchell-Netravalli*:
:math:`(a=\\cfrac{1}{3}, b=\\cfrac{1}{3})`
Parameters
----------
x
Samples at which to evaluate the *cardinal spline* kernel.
a
:math:`a` control parameter.
b
:math:`b` control parameter.
Returns
-------
:class:`numpy.ndarray`
The *cardinal spline* kernel evaluated at specified samples.
References
----------
:cite:`Burger2009b`
Examples
--------
>>> kernel_cardinal_spline(np.linspace(0, 1, 10)) # doctest: +ELLIPSIS
array([ 1. , 0.9711934..., 0.8930041..., \
0.7777777..., 0.6378600...,
0.4855967..., 0.3333333..., 0.1934156..., \
0.0781893..., 0. ])
"""
x = as_float_array(x)
x_abs = np.abs(x)
y = np.where(
x_abs < 1,
(-6 * a - 9 * b + 12) * x_abs**3 + (6 * a + 12 * b - 18) * x_abs**2 - 2 * b + 6,
(-6 * a - b) * x_abs**3
+ (30 * a + 6 * b) * x_abs**2
+ (-48 * a - 12 * b) * x_abs
+ 24 * a
+ 8 * b,
)
y[x_abs >= 2] = 0
return 1 / 6 * y
[docs]
class KernelInterpolator:
"""
Perform kernel-based interpolation of a 1-D function.
Reconstruct a continuous signal from discrete samples using linear
convolution. Express interpolation as the convolution of the specified
discrete function :math:`g(x)` with a continuous interpolation kernel
:math:`k(w)`:
:math:`\\hat{g}(w_0) = [k * g](w_0) = \
\\sum_{x=-\\infty}^{\\infty}k(w_0 - x)\\cdot g(x)`
Parameters
----------
x
Independent :math:`x` variable values corresponding with :math:`y`
variable.
y
Dependent and already known :math:`y` variable values to interpolate.
window
Width of the window in samples on each side.
kernel
Kernel to use for interpolation.
kernel_kwargs
Arguments to use when calling the kernel.
padding_kwargs
Arguments to use when padding :math:`y` variable values with the
:func:`np.pad` definition.
dtype
Data type used for internal conversions.
Attributes
----------
- :attr:`~colour.KernelInterpolator.x`
- :attr:`~colour.KernelInterpolator.y`
- :attr:`~colour.KernelInterpolator.window`
- :attr:`~colour.KernelInterpolator.kernel`
- :attr:`~colour.KernelInterpolator.kernel_kwargs`
- :attr:`~colour.KernelInterpolator.padding_kwargs`
Methods
-------
- :meth:`~colour.KernelInterpolator.__init__`
- :meth:`~colour.KernelInterpolator.__call__`
References
----------
:cite:`Burger2009b`, :cite:`Wikipedia2005b`
Examples
--------
Interpolating a single numeric variable:
>>> y = np.array(
... [5.9200, 9.3700, 10.8135, 4.5100, 69.5900, 27.8007, 86.0500]
... )
>>> x = np.arange(len(y))
>>> f = KernelInterpolator(x, y)
>>> f(0.5) # doctest: +ELLIPSIS
6.9411400...
Interpolating an `ArrayLike` variable:
>>> f([0.25, 0.75]) # doctest: +ELLIPSIS
array([ 6.1806208..., 8.0823848...])
Using a different *lanczos* kernel:
>>> f = KernelInterpolator(x, y, kernel=kernel_sinc)
>>> f([0.25, 0.75]) # doctest: +ELLIPSIS
array([ 6.5147317..., 8.3965466...])
Using a different window size:
>>> f = KernelInterpolator(
... x, y, window=16, kernel=kernel_lanczos, kernel_kwargs={"a": 16}
... )
>>> f([0.25, 0.75]) # doctest: +ELLIPSIS
array([ 5.3961792..., 5.6521093...])
"""
[docs]
def __init__(
self,
x: ArrayLike,
y: ArrayLike,
window: float = 3,
kernel: Callable = kernel_lanczos,
kernel_kwargs: dict | None = None,
padding_kwargs: dict | None = None,
dtype: Type[DTypeReal] | None = None,
*args: Any, # noqa: ARG002
**kwargs: Any, # noqa: ARG002
) -> None:
dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)
self._x_p: NDArrayFloat = np.array([])
self._y_p: NDArrayFloat = np.array([])
self._x: NDArrayFloat = np.array([])
self._y: NDArrayFloat = np.array([])
self._window: float = 3
self._padding_kwargs: dict = {
"pad_width": (window, window),
"mode": "reflect",
}
self._kernel: Callable = kernel_lanczos
self._kernel_kwargs: dict = {}
self._dtype: Type[DTypeReal] = dtype
self.x = x
self.y = y
self.window = window
self.padding_kwargs = optional(padding_kwargs, self._padding_kwargs)
self.kernel = kernel
self.kernel_kwargs = optional(kernel_kwargs, self._kernel_kwargs)
self._validate_dimensions()
@property
def x(self) -> NDArrayFloat:
"""
Getter and setter for the independent :math:`x` variable.
Parameters
----------
value
Value to set the independent :math:`x` variable with.
Returns
-------
:class:`numpy.ndarray`
Independent :math:`x` variable.
"""
return self._x
@x.setter
def x(self, value: ArrayLike) -> None:
"""Setter for the **self.x** property."""
value = np.atleast_1d(value).astype(self._dtype)
attest(
value.ndim == 1,
'"x" independent variable must have exactly one dimension!',
)
value_interval = interval(value)
if value_interval.size != 1:
runtime_warning(
'"x" independent variable is not uniform, '
"unpredictable results may occur!"
)
self._x = as_array(value, self._dtype)
self._x_p = np.pad(
self._x,
as_int_array([self._window, self._window]),
"linear_ramp",
end_values=(
np.min(self._x) - self._window * value_interval[0],
np.max(self._x) + self._window * value_interval[0],
),
)
@property
def y(self) -> NDArrayFloat:
"""
Getter and setter for the dependent and already known :math:`y` variable.
Parameters
----------
value
Value to set the dependent and already known :math:`y` variable
with.
Returns
-------
:class:`numpy.ndarray`
Dependent and already known :math:`y` variable.
"""
return self._y
@y.setter
def y(self, value: ArrayLike) -> None:
"""Setter for the **self.y** property."""
value = np.atleast_1d(value).astype(self._dtype)
attest(
value.ndim == 1,
'"y" dependent variable must have exactly one dimension!',
)
self._y = as_array(value, self._dtype)
if self._window is not None:
self._y_p = np.pad(self._y, **self._padding_kwargs)
@property
def window(self) -> float:
"""
Getter and setter for the filtering window size for the moving average.
The window determines the number of samples used in the moving
average calculation. A larger window produces smoother results
with greater lag, while a smaller window yields more responsive
but potentially noisier output.
Parameters
----------
value
Value to set the window with.
Returns
-------
:class:`float`
Window size for the moving average filter.
"""
return self._window
@window.setter
def window(self, value: float) -> None:
"""Setter for the **self.window** property."""
attest(bool(value >= 1), '"window" must be equal to or greater than 1!')
self._window = value
# Triggering "self._x_p" update.
if self._x is not None:
self.x = self._x
# Triggering "self._y_p" update.
if self._y is not None:
self.y = self._y
@property
def kernel(self) -> Callable:
"""
Getter and setter for the kernel callable for the interpolator.
Parameters
----------
value
Value to set the callable object to use as the interpolation kernel
with. Must be a callable that accepts numeric arguments.
Returns
-------
Callable
Callable object to use as the interpolation kernel.
Raises
------
AssertionError
If the provided value is not callable.
"""
return self._kernel
@kernel.setter
def kernel(self, value: Callable) -> None:
"""Setter for the **self.kernel** property."""
attest(
callable(value),
f'"kernel" property: "{value}" is not callable!',
)
self._kernel = value
@property
def kernel_kwargs(self) -> dict:
"""
Getter and setter for the kernel keyword arguments for the convolution
operation.
Parameters
----------
value
Value to set the keyword arguments to pass to the kernel function
with.
Returns
-------
:class:`dict`
Keyword arguments to pass to the kernel function.
Raises
------
AssertionError
If the provided value is not a :class:'dict` class instance.
"""
return self._kernel_kwargs
@kernel_kwargs.setter
def kernel_kwargs(self, value: dict) -> None:
"""Setter for the **self.kernel_kwargs** property."""
attest(
isinstance(value, dict),
f'"kernel_kwargs" property: "{value}" type is not "dict"!',
)
self._kernel_kwargs = value
@property
def padding_kwargs(self) -> dict:
"""
Getter and setter for the padding keyword arguments for edge handling.
Parameters
----------
value
Value to set the keyword arguments to pass to the padding function
when handling edges during interpolation.
Returns
-------
:class:`dict`
Keyword arguments to pass to the padding function when handling
edges during interpolation.
Raises
------
AssertionError
If the provided value is not a :class:`dict` class instance.
"""
return self._padding_kwargs
@padding_kwargs.setter
def padding_kwargs(self, value: dict) -> None:
"""Setter for the **self.padding_kwargs** property."""
attest(
isinstance(value, dict),
f'"padding_kwargs" property: "{value}" type is not a "dict" instance!',
)
self._padding_kwargs = value
# Triggering "self._y_p" update.
if self._y is not None:
self.y = self._y
[docs]
def __call__(self, x: ArrayLike) -> NDArrayFloat:
"""
Evaluate the interpolator at specified point(s).
Parameters
----------
x
Point(s) to evaluate the interpolant at.
Returns
-------
:class:`numpy.ndarray`
Interpolated value(s).
"""
x = as_float_array(x)
xi = self._evaluate(x)
return as_float(xi)
def _evaluate(self, x: NDArrayFloat) -> NDArrayFloat:
"""
Evaluate the interpolating polynomial at the specified point.
Parameters
----------
x
Point at which to evaluate the interpolant.
Returns
-------
:class:`numpy.ndarray`
Interpolated values at the specified point.
"""
self._validate_dimensions()
self._validate_interpolation_range(x)
x_interval = interval(self._x)[0]
x_f = np.floor(x / x_interval)
windows = x_f[..., None] + np.arange(-self._window + 1, self._window + 1)
clip_l = min(self._x_p) / x_interval
clip_h = max(self._x_p) / x_interval
windows = np.clip(windows, clip_l, clip_h) - clip_l
windows = as_int_array(np.around(windows))
return np.sum(
self._y_p[windows]
* self._kernel(
x[..., None] / x_interval - windows - min(self._x_p) / x_interval,
**self._kernel_kwargs,
),
axis=-1,
)
def _validate_dimensions(self) -> None:
"""
Validate that the dimensions of the variables are equal.
Raises
------
ValueError
If the x and y variable dimensions do not match.
"""
if len(self._x) != len(self._y):
error = (
'"x" independent and "y" dependent variables have different '
f'dimensions: "{len(self._x)}", "{len(self._y)}"'
)
raise ValueError(error)
def _validate_interpolation_range(self, x: NDArrayFloat) -> None:
"""
Validate that the specified interpolation point is within the valid
interpolation range.
The interpolation point must be within the bounds defined by the first
and last x-coordinates of the interpolator's data.
Parameters
----------
x
Point to validate for interpolation range compliance.
Raises
------
ValueError
If the point is outside the valid interpolation range.
"""
below_interpolation_range = x < self._x[0]
above_interpolation_range = x > self._x[-1]
if below_interpolation_range.any():
error = f'"{x}" is below interpolation range.'
raise ValueError(error)
if above_interpolation_range.any():
error = f'"{x}" is above interpolation range.'
raise ValueError(error)
[docs]
class NearestNeighbourInterpolator(KernelInterpolator):
"""
Perform nearest-neighbour interpolation on discrete data.
Implement a kernel-based interpolator that selects the closest known
data point for each query position. This interpolator provides fast,
discontinuous interpolation suitable for categorical data or when
preserving exact measured values is required.
Other Parameters
----------------
dtype
Data type used for internal conversions.
padding_kwargs
Arguments to use when padding :math:`y` variable values with the
:func:`np.pad` definition.
window
Width of the window in samples on each side.
x
Independent :math:`x` variable values corresponding with :math:`y`
variable.
y
Dependent and already known :math:`y` variable values to
interpolate.
Methods
-------
- :meth:`~colour.NearestNeighbourInterpolator.__init__`
"""
[docs]
def __init__(self, *args: Any, **kwargs: Any) -> None:
kwargs["kernel"] = kernel_nearest_neighbour
kwargs.pop("kernel_kwargs", None)
super().__init__(*args, **kwargs)
[docs]
class LinearInterpolator:
"""
Perform linear interpolation of a 1-D function.
This class provides a wrapper around NumPy's linear interpolation
functionality for interpolating between specified data points.
Parameters
----------
x
Independent :math:`x` variable values corresponding with :math:`y`
variable.
y
Dependent and already known :math:`y` variable values to
interpolate.
dtype
Data type used for internal conversions.
Attributes
----------
- :attr:`~colour.LinearInterpolator.x`
- :attr:`~colour.LinearInterpolator.y`
Methods
-------
- :meth:`~colour.LinearInterpolator.__init__`
- :meth:`~colour.LinearInterpolator.__call__`
Notes
-----
- This class is a wrapper around *numpy.interp* definition.
Examples
--------
Interpolating a single numeric variable:
>>> y = np.array([5.9200, 9.3700, 10.8135, 4.5100, 69.5900, 27.8007, 86.0500])
>>> x = np.arange(len(y))
>>> f = LinearInterpolator(x, y)
>>> f(0.5) # doctest: +ELLIPSIS
7.64...
Interpolating an `ArrayLike` variable:
>>> f([0.25, 0.75])
array([ 6.7825, 8.5075])
"""
[docs]
def __init__(
self,
x: ArrayLike,
y: ArrayLike,
dtype: Type[DTypeReal] | None = None,
*args: Any, # noqa: ARG002
**kwargs: Any, # noqa: ARG002
) -> None:
dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)
self._x: NDArrayFloat = np.array([])
self._y: NDArrayFloat = np.array([])
self._dtype: Type[DTypeReal] = dtype
self.x = x
self.y = y
self._validate_dimensions()
@property
def x(self) -> NDArrayFloat:
"""
Getter and setter for the independent :math:`x` variable.
Parameters
----------
value
Value to set the independent :math:`x` variable with.
Returns
-------
:class:`numpy.ndarray`
Independent :math:`x` variable.
Raises
------
AssertionError
If the provided value has not exactly one dimension.
"""
return self._x
@x.setter
def x(self, value: ArrayLike) -> None:
"""Setter for the **self.x** property."""
value = cast("NDArrayFloat", np.atleast_1d(value).astype(self._dtype))
attest(
value.ndim == 1,
'"x" independent variable must have exactly one dimension!',
)
self._x = value
@property
def y(self) -> NDArrayFloat:
"""
Getter and setter for the dependent and already known
:math:`y` variable.
Parameters
----------
value
Value to set the dependent and already known :math:`y` variable
with.
Returns
-------
:class:`numpy.ndarray`
Dependent and already known :math:`y` variable.
Raises
------
AssertionError
If the provided value has not exactly one dimension.
"""
return self._y
@y.setter
def y(self, value: ArrayLike) -> None:
"""Setter for the **self.y** property."""
value = cast("NDArrayFloat", np.atleast_1d(value).astype(self._dtype))
attest(
value.ndim == 1,
'"y" dependent variable must have exactly one dimension!',
)
self._y = value
[docs]
def __call__(self, x: ArrayLike) -> NDArrayFloat:
"""
Evaluate the interpolating polynomial at specified point(s).
Parameters
----------
x
Point(s) to evaluate the interpolant at.
Returns
-------
:class:`numpy.ndarray`
Interpolated value(s).
"""
x = as_float_array(x)
xi = self._evaluate(x)
return as_float(xi)
def _evaluate(self, x: NDArrayFloat) -> NDArrayFloat:
"""
Perform the interpolating polynomial evaluation at specified points.
Parameters
----------
x
Points to evaluate the interpolant at.
Returns
-------
:class:`numpy.ndarray`
Interpolated points values.
"""
self._validate_dimensions()
self._validate_interpolation_range(x)
return np.interp(x, self._x, self._y)
def _validate_dimensions(self) -> None:
"""Validate that the variables dimensions are the same."""
if len(self._x) != len(self._y):
error = (
'"x" independent and "y" dependent variables have different '
f'dimensions: "{len(self._x)}", "{len(self._y)}"'
)
raise ValueError(error)
def _validate_interpolation_range(self, x: NDArrayFloat) -> None:
"""Validate specified point to be in interpolation range."""
below_interpolation_range = x < self._x[0]
above_interpolation_range = x > self._x[-1]
if below_interpolation_range.any():
error = f'"{x}" is below interpolation range.'
raise ValueError(error)
if above_interpolation_range.any():
error = f'"{x}" is above interpolation range.'
raise ValueError(error)
[docs]
class SpragueInterpolator:
"""
Perform fifth-order polynomial interpolation using the *Sprague (1880)*
method for uniformly spaced data.
Implement the *Sprague (1880)* interpolation method recommended by the
*CIE* for interpolating functions with uniformly spaced independent
variables. This interpolator constructs a fifth-order polynomial that
passes through specified dependent variable values, providing smooth
interpolation suitable for spectral data and other colour science
applications.
Parameters
----------
x
Independent :math:`x` variable values corresponding with :math:`y`
variable.
y
Dependent and already known :math:`y` variable values to
interpolate.
dtype
Data type used for internal conversions.
Attributes
----------
- :attr:`~colour.SpragueInterpolator.x`
- :attr:`~colour.SpragueInterpolator.y`
Methods
-------
- :meth:`~colour.SpragueInterpolator.__init__`
- :meth:`~colour.SpragueInterpolator.__call__`
Notes
-----
- The minimum number :math:`k` of data points required along the
interpolation axis is :math:`k=6`.
References
----------
:cite:`CIETC1-382005f`, :cite:`Westland2012h`
Examples
--------
Interpolating a single numeric variable:
>>> y = np.array([5.9200, 9.3700, 10.8135, 4.5100, 69.5900, 27.8007, 86.0500])
>>> x = np.arange(len(y))
>>> f = SpragueInterpolator(x, y)
>>> f(0.5) # doctest: +ELLIPSIS
7.2185025...
Interpolating an `ArrayLike` variable:
>>> f([0.25, 0.75]) # doctest: +ELLIPSIS
array([ 6.7295161..., 7.8140625...])
"""
SPRAGUE_C_COEFFICIENTS = np.array(
[
[884, -1960, 3033, -2648, 1080, -180],
[508, -540, 488, -367, 144, -24],
[-24, 144, -367, 488, -540, 508],
[-180, 1080, -2648, 3033, -1960, 884],
]
)
"""
Defines the coefficients used to generate extra points for boundaries
interpolation.
SPRAGUE_C_COEFFICIENTS, (4, 6)
References
----------
:cite:`CIETC1-382005h`
"""
[docs]
def __init__(
self,
x: ArrayLike,
y: ArrayLike,
dtype: Type[DTypeReal] | None = None,
*args: Any, # noqa: ARG002
**kwargs: Any, # noqa: ARG002
) -> None:
dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)
self._xp: NDArrayFloat = np.array([])
self._yp: NDArrayFloat = np.array([])
self._x: NDArrayFloat = np.array([])
self._y: NDArrayFloat = np.array([])
self._dtype: Type[DTypeReal] = dtype
self.x = x
self.y = y
self._validate_dimensions()
@property
def x(self) -> NDArrayFloat:
"""
Getter and setter for the independent :math:`x` variable.
Parameters
----------
value
Value to set the independent :math:`x` variable with.
Returns
-------
:class:`numpy.ndarray`
Independent :math:`x` variable.
Raises
------
AssertionError
If the provided value has not exactly one dimension.
"""
return self._x
@x.setter
def x(self, value: ArrayLike) -> None:
"""Setter for the **self.x** property."""
value = as_array(np.atleast_1d(value), self._dtype)
attest(
value.ndim == 1,
'"x" independent variable must have exactly one dimension!',
)
self._x = value
value_interval = interval(self._x)[0]
xp1 = self._x[0] - value_interval * 2
xp2 = self._x[0] - value_interval
xp3 = self._x[-1] + value_interval
xp4 = self._x[-1] + value_interval * 2
self._xp = np.concatenate(
[
as_array([xp1, xp2], self._dtype),
value,
as_array([xp3, xp4], self._dtype),
]
)
@property
def y(self) -> NDArrayFloat:
"""
Getter and setter for the dependent and already known
:math:`y` variable.
Parameters
----------
value
Value to set the dependent and already known :math:`y` variable
with.
Returns
-------
:class:`numpy.ndarray`
Dependent and already known :math:`y` variable.
Raises
------
AssertionError
If the provided value has not exactly one dimension and its value
count is less than 6.
"""
return self._y
@y.setter
def y(self, value: ArrayLike) -> None:
"""Setter for the **self.y** property."""
value = as_array(np.atleast_1d(value), self._dtype)
attest(
value.ndim == 1,
'"y" dependent variable must have exactly one dimension!',
)
attest(
len(value) >= 6,
'"y" dependent variable values count must be equal to or greater than 6!',
)
self._y = value
yp1, yp2, yp3, yp4 = (
np.sum(
self.SPRAGUE_C_COEFFICIENTS
* np.asarray((value[0:6], value[0:6], value[-6:], value[-6:])),
axis=1,
)
/ 209
)
self._yp = np.concatenate(
[
as_array([yp1, yp2], self._dtype),
value,
as_array([yp3, yp4], self._dtype),
]
)
[docs]
def __call__(self, x: ArrayLike) -> NDArrayFloat:
"""
Evaluate the interpolating polynomial at specified point(s).
Parameters
----------
x
Point(s) to evaluate the interpolant at.
Returns
-------
:class:`numpy.ndarray`
Interpolated value(s).
"""
x = as_float_array(x)
xi = self._evaluate(x)
return as_float(xi)
def _evaluate(self, x: NDArrayFloat) -> NDArrayFloat:
"""
Perform the interpolating polynomial evaluation at specified point.
Parameters
----------
x
Point to evaluate the interpolant at.
Returns
-------
:class:`numpy.ndarray`
Interpolated point values.
"""
self._validate_dimensions()
self._validate_interpolation_range(x)
i = np.searchsorted(self._xp, x) - 1
with sdiv_mode():
X = sdiv(x - self._xp[i], self._xp[i + 1] - self._xp[i])
r = self._yp
r_s = np.asarray((r[i - 2], r[i - 1], r[i], r[i + 1], r[i + 2], r[i + 3]))
w_s = np.asarray(
(
(2, -16, 0, 16, -2, 0),
(-1, 16, -30, 16, -1, 0),
(-9, 39, -70, 66, -33, 7),
(13, -64, 126, -124, 61, -12),
(-5, 25, -50, 50, -25, 5),
)
)
a = np.dot(w_s, r_s) / 24
# Fancy vector code here... use underlying numpy structures to accelerate
# parts of the linear algebra.
y = r[i] + (a.reshape(5, -1) * X ** np.arange(1, 6).reshape(-1, 1)).sum(axis=0)
if y.size == 1:
return y[0]
return y
def _validate_dimensions(self) -> None:
"""Validate that the variables dimensions are the same."""
if len(self._x) != len(self._y):
error = (
'"x" independent and "y" dependent variables have different '
f'dimensions: "{len(self._x)}", "{len(self._y)}"'
)
raise ValueError(error)
def _validate_interpolation_range(self, x: NDArrayFloat) -> None:
"""Validate specified point to be in interpolation range."""
below_interpolation_range = x < self._x[0]
above_interpolation_range = x > self._x[-1]
if below_interpolation_range.any():
error = f'"{x}" is below interpolation range.'
raise ValueError(error)
if above_interpolation_range.any():
error = f'"{x}" is above interpolation range.'
raise ValueError(error)
class CubicSplineInterpolator(scipy.interpolate.interp1d):
"""
Perform cubic spline interpolation on one-dimensional data.
Provide smooth interpolation through specified data points using
piecewise cubic polynomials. The resulting interpolant maintains
continuity in the function and its first two derivatives at data
points, making it suitable for spectral data and colour science
applications requiring smooth transitions between measured values.
Methods
-------
- :meth:`~colour.CubicSplineInterpolator.__init__`
Notes
-----
- This class is a wrapper around *scipy.interpolate.interp1d* class.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
kwargs["kind"] = "cubic"
super().__init__(*args, **kwargs)
[docs]
class PchipInterpolator(scipy.interpolate.PchipInterpolator):
"""
Interpolate a 1-D function using Piecewise Cubic Hermite Interpolating
Polynomial (PCHIP) interpolation.
PCHIP interpolation constructs a smooth curve through specified data
points while preserving monotonicity between consecutive points. This
method ensures that the interpolated values do not exhibit spurious
oscillations, making it particularly suitable for colour science
applications where physical constraints must be respected.
Attributes
----------
- :attr:`~colour.PchipInterpolator.y`
Methods
-------
- :meth:`~colour.PchipInterpolator.__init__`
Notes
-----
- This class is a wrapper around *scipy.interpolate.PchipInterpolator*
class.
"""
[docs]
def __init__(self, x: ArrayLike, y: ArrayLike, *args: Any, **kwargs: Any) -> None:
super().__init__(as_float_array(x), as_float_array(y), *args, **kwargs)
self._y: NDArrayFloat = as_float_array(y)
@property
def y(self) -> NDArrayFloat:
"""
Getter and setter for the dependent and already known
:math:`y` variable.
Parameters
----------
value
Value to set the dependent and already known :math:`y` variable
with.
Returns
-------
:class:`numpy.ndarray`
Dependent and already known :math:`y` variable.
"""
return self._y
@y.setter
def y(self, value: ArrayLike) -> None:
"""Setter for the **self.y** property."""
self._y = as_float_array(value)
[docs]
class NullInterpolator:
"""
Implement 1-D function null interpolation.
This interpolator returns existing :math:`y` values when called with
:math:`x` values within specified tolerances, and returns a default
value when outside tolerances. Unlike traditional interpolators that
estimate intermediate values, this null interpolator only returns exact
matches within tolerance bounds.
Parameters
----------
x
Independent :math:`x` variable values corresponding with :math:`y`
variable.
y
Dependent and already known :math:`y` variable values to
interpolate.
absolute_tolerance
Absolute tolerance.
relative_tolerance
Relative tolerance.
default
Default value for interpolation outside tolerances.
dtype
Data type used for internal conversions.
Attributes
----------
- :attr:`~colour.NullInterpolator.x`
- :attr:`~colour.NullInterpolator.y`
- :attr:`~colour.NullInterpolator.relative_tolerance`
- :attr:`~colour.NullInterpolator.absolute_tolerance`
- :attr:`~colour.NullInterpolator.default`
Methods
-------
- :meth:`~colour.NullInterpolator.__init__`
- :meth:`~colour.NullInterpolator.__call__`
Examples
--------
>>> y = np.array([5.9200, 9.3700, 10.8135, 4.5100, 69.5900, 27.8007, 86.0500])
>>> x = np.arange(len(y))
>>> f = NullInterpolator(x, y)
>>> f(0.5)
nan
>>> f(1.0) # doctest: +ELLIPSIS
9.3699999...
>>> f = NullInterpolator(x, y, absolute_tolerance=0.01)
>>> f(1.01) # doctest: +ELLIPSIS
9.3699999...
"""
[docs]
def __init__(
self,
x: ArrayLike,
y: ArrayLike,
absolute_tolerance: float = TOLERANCE_ABSOLUTE_DEFAULT,
relative_tolerance: float = TOLERANCE_RELATIVE_DEFAULT,
default: float = np.nan,
dtype: Type[DTypeReal] | None = None,
*args: Any, # noqa: ARG002
**kwargs: Any, # noqa: ARG002
) -> None:
dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)
self._x: NDArrayFloat = np.array([])
self._y: NDArrayFloat = np.array([])
self._absolute_tolerance: float = TOLERANCE_ABSOLUTE_DEFAULT
self._relative_tolerance: float = TOLERANCE_RELATIVE_DEFAULT
self._default: float = np.nan
self._dtype: Type[DTypeReal] = dtype
self.x = x
self.y = y
self.absolute_tolerance = absolute_tolerance
self.relative_tolerance = relative_tolerance
self.default = default
self._validate_dimensions()
@property
def x(self) -> NDArrayFloat:
"""
Getter and setter for the independent :math:`x` variable.
Parameters
----------
value
Value to set the independent :math:`x` variable with.
Returns
-------
:class:`numpy.ndarray`
Independent :math:`x` variable.
Raises
------
AssertionError
If the provided value has not exactly one dimension.
"""
return self._x
@x.setter
def x(self, value: ArrayLike) -> None:
"""Setter for the **self.x** property."""
value = cast("NDArrayFloat", np.atleast_1d(value).astype(self._dtype))
attest(
value.ndim == 1,
'"x" independent variable must have exactly one dimension!',
)
self._x = value
@property
def y(self) -> NDArrayFloat:
"""
Getter and setter for the dependent and already known
:math:`y` variable.
Parameters
----------
value
Value to set the dependent and already known :math:`y` variable
with.
Returns
-------
:class:`numpy.ndarray`
Dependent and already known :math:`y` variable.
Raises
------
AssertionError
If the provided value has not exactly one dimension.
"""
return self._y
@y.setter
def y(self, value: ArrayLike) -> None:
"""Setter for the **self.y** property."""
value = cast("NDArrayFloat", np.atleast_1d(value).astype(self._dtype))
attest(
value.ndim == 1,
'"y" dependent variable must have exactly one dimension!',
)
self._y = value
@property
def relative_tolerance(self) -> float:
"""
Getter and setter property for the relative tolerance for numerical
comparisons.
Parameters
----------
value
Value to set the relative tolerance for numerical comparisons with.
Returns
-------
:class:`float`
Relative tolerance for numerical comparisons.
Raises
------
AssertionError
If the value is not numeric.
"""
return self._relative_tolerance
@relative_tolerance.setter
def relative_tolerance(self, value: float) -> None:
"""Setter for the **self.relative_tolerance** property."""
attest(
is_numeric(value),
'"relative_tolerance" variable must be a "numeric"!',
)
self._relative_tolerance = as_float_scalar(value)
@property
def absolute_tolerance(self) -> float:
"""
Getter and setter property for the absolute tolerance for numerical
comparisons.
Parameters
----------
value
Value to set the absolute tolerance for numerical comparisons with.
Returns
-------
:class:`float`
Absolute tolerance for numerical comparisons.
Raises
------
AssertionError
If the value is not numeric.
"""
return self._absolute_tolerance
@absolute_tolerance.setter
def absolute_tolerance(self, value: float) -> None:
"""Setter for the **self.absolute_tolerance** property."""
attest(
is_numeric(value),
'"absolute_tolerance" variable must be a "numeric"!',
)
self._absolute_tolerance = as_float_scalar(value)
@property
def default(self) -> float:
"""
Getter and setter property for the default value for call outside
tolerances.
Parameters
----------
value
Value to set the default value with for call outside tolerances.
Returns
-------
:class:`float`
Default value for call outside tolerances.
Raises
------
AssertionError
If the value is not numeric.
"""
return self._default
@default.setter
def default(self, value: float) -> None:
"""Setter for the **self.default** property."""
attest(is_numeric(value), '"default" variable must be a "numeric"!')
self._default = value
[docs]
def __call__(self, x: ArrayLike) -> NDArrayFloat:
"""
Evaluate the interpolator at specified point(s).
Parameters
----------
x
Point(s) to evaluate the interpolant at.
Returns
-------
:class:`numpy.ndarray`
Interpolated value(s).
"""
x = as_float_array(x)
xi = self._evaluate(x)
return as_float(xi)
def _evaluate(self, x: NDArrayFloat) -> NDArrayFloat:
"""
Perform the interpolator evaluation at specified points.
Parameters
----------
x
Points to evaluate the interpolant at.
Returns
-------
:class:`numpy.ndarray`
Interpolated points values.
"""
self._validate_dimensions()
self._validate_interpolation_range(x)
indexes = closest_indexes(self._x, x)
values = self._y[indexes]
values[
~np.isclose(
self._x[indexes],
x,
rtol=self._absolute_tolerance,
atol=self._relative_tolerance,
)
] = self._default
return np.squeeze(values)
def _validate_dimensions(self) -> None:
"""Validate that the variables dimensions are the same."""
if len(self._x) != len(self._y):
error = (
'"x" independent and "y" dependent variables have different '
f'dimensions: "{len(self._x)}", "{len(self._y)}"'
)
raise ValueError(error)
def _validate_interpolation_range(self, x: NDArrayFloat) -> None:
"""Validate specified point to be in interpolation range."""
below_interpolation_range = x < self._x[0]
above_interpolation_range = x > self._x[-1]
if below_interpolation_range.any():
error = f'"{x}" is below interpolation range.'
raise ValueError(error)
if above_interpolation_range.any():
error = f'"{x}" is above interpolation range.'
raise ValueError(error)
[docs]
def lagrange_coefficients(r: float, n: int = 4) -> NDArrayFloat:
"""
Compute *Lagrange coefficients* at specified point :math:`r` for
polynomial interpolation of degree :math:`n`.
Parameters
----------
r
Point at which to compute the *Lagrange coefficients*.
n
Degree of the polynomial interpolation. The number of coefficients
returned will be :math:`n + 1`.
Returns
-------
:class:`numpy.ndarray`
Array of *Lagrange coefficients* computed at point :math:`r`.
References
----------
:cite:`Fairman1985b`, :cite:`Wikipedia2003a`
Examples
--------
>>> lagrange_coefficients(0.1)
array([ 0.8265, 0.2755, -0.1305, 0.0285])
"""
r_i = np.arange(n)
L_n = []
for j in range(len(r_i)):
basis = [(r - r_i[i]) / (r_i[j] - r_i[i]) for i in range(len(r_i)) if i != j]
L_n.append(reduce(lambda x, y: x * y, basis))
return np.array(L_n)
[docs]
def table_interpolation_trilinear(V_xyz: ArrayLike, table: ArrayLike) -> NDArrayFloat:
"""
Perform trilinear interpolation of the specified :math:`V_{xyz}` values using
the specified interpolation table.
Parameters
----------
V_xyz
:math:`V_{xyz}` values to interpolate.
table
4-Dimensional (NxNxNx3) interpolation table.
Returns
-------
:class:`numpy.ndarray`
Interpolated :math:`V_{xyz}` values.
References
----------
:cite:`Bourkeb`
Examples
--------
>>> import os
>>> import colour
>>> path = os.path.join(
... os.path.dirname(__file__),
... "..",
... "io",
... "luts",
... "tests",
... "resources",
... "iridas_cube",
... "Colour_Correct.cube",
... )
>>> LUT = colour.read_LUT(path)
>>> table = LUT.table
>>> prng = np.random.RandomState(4)
>>> V_xyz = colour.algebra.random_triplet_generator(3, random_state=prng)
>>> print(V_xyz) # doctest: +ELLIPSIS
[[ 0.9670298... 0.7148159... 0.9762744...]
[ 0.5472322... 0.6977288... 0.0062302...]
[ 0.9726843... 0.2160895... 0.2529823...]]
>>> table_interpolation_trilinear(V_xyz, table) # doctest: +ELLIPSIS
array([[ 1.0120664..., 0.7539146..., 1.0228540...],
[ 0.5075794..., 0.6479459..., 0.1066404...],
[ 1.0976519..., 0.1785998..., 0.2299897...]])
"""
V_xyz = cast("NDArrayFloat", V_xyz)
original_shape = V_xyz.shape
V_xyz = cast("NDArrayFloat", np.clip(V_xyz, 0, 1).reshape(-1, 3))
# Index computation
table = cast("NDArrayFloat", table)
i_m = np.array(table.shape[:-1]) - 1
V_xyz_s = V_xyz * i_m
i_f = V_xyz_s.astype(DTYPE_INT_DEFAULT)
i_f = np.clip(i_f, 0, i_m)
i_c = np.minimum(i_f + 1, i_m)
# Relative coordinates (fractional part)
frac = V_xyz_s - i_f
# Extract indices for direct lookup
fx, fy, fz = i_f[:, 0], i_f[:, 1], i_f[:, 2]
cx, cy, cz = i_c[:, 0], i_c[:, 1], i_c[:, 2]
# Extract fractional coordinates
dx, dy, dz = frac[:, 0:1], frac[:, 1:2], frac[:, 2:3]
dx1, dy1, dz1 = 1.0 - dx, 1.0 - dy, 1.0 - dz
# Direct vertex lookups (8 corners of cube)
v000 = table[fx, fy, fz]
v001 = table[fx, fy, cz]
v010 = table[fx, cy, fz]
v011 = table[fx, cy, cz]
v100 = table[cx, fy, fz]
v101 = table[cx, fy, cz]
v110 = table[cx, cy, fz]
v111 = table[cx, cy, cz]
# Trilinear interpolation (vectorized)
result = (
v000 * (dx1 * dy1 * dz1)
+ v001 * (dx1 * dy1 * dz)
+ v010 * (dx1 * dy * dz1)
+ v011 * (dx1 * dy * dz)
+ v100 * (dx * dy1 * dz1)
+ v101 * (dx * dy1 * dz)
+ v110 * (dx * dy * dz1)
+ v111 * (dx * dy * dz)
)
return result.reshape(original_shape)
[docs]
def table_interpolation_tetrahedral(V_xyz: ArrayLike, table: ArrayLike) -> NDArrayFloat:
"""
Perform tetrahedral interpolation of the specified :math:`V_{xyz}` values
using the specified 4-dimensional interpolation table.
Parameters
----------
V_xyz
:math:`V_{xyz}` values to interpolate.
table
4-Dimensional (NxNxNx3) interpolation table.
Returns
-------
:class:`numpy.ndarray`
Interpolated :math:`V_{xyz}` values.
References
----------
:cite:`Kirk2006`
Examples
--------
>>> import os
>>> import colour
>>> path = os.path.join(
... os.path.dirname(__file__),
... "..",
... "io",
... "luts",
... "tests",
... "resources",
... "iridas_cube",
... "Colour_Correct.cube",
... )
>>> LUT = colour.read_LUT(path)
>>> table = LUT.table
>>> prng = np.random.RandomState(4)
>>> V_xyz = colour.algebra.random_triplet_generator(3, random_state=prng)
>>> print(V_xyz) # doctest: +ELLIPSIS
[[ 0.9670298... 0.7148159... 0.9762744...]
[ 0.5472322... 0.6977288... 0.0062302...]
[ 0.9726843... 0.2160895... 0.2529823...]]
>>> table_interpolation_tetrahedral(V_xyz, table) # doctest: +ELLIPSIS
array([[ 1.0196197..., 0.7674062..., 1.0311751...],
[ 0.5105603..., 0.6466722..., 0.1077296...],
[ 1.1178206..., 0.1762039..., 0.2209534...]])
"""
V_xyz = cast("NDArrayFloat", V_xyz)
original_shape = V_xyz.shape
V_xyz = cast("NDArrayFloat", np.clip(V_xyz, 0, 1).reshape(-1, 3))
# Index computation
table = cast("NDArrayFloat", table)
i_m = np.array(table.shape[:-1]) - 1
V_xyz_s = V_xyz * i_m
i_f = V_xyz_s.astype(DTYPE_INT_DEFAULT)
i_f = np.clip(i_f, 0, i_m)
i_c = np.minimum(i_f + 1, i_m)
# Relative coordinates
r = V_xyz_s - i_f
x, y, z = r[:, 0], r[:, 1], r[:, 2]
# Extract indices for direct lookup
fx, fy, fz = i_f[:, 0], i_f[:, 1], i_f[:, 2]
cx, cy, cz = i_c[:, 0], i_c[:, 1], i_c[:, 2]
# Look up 8 corner vertices
V000 = table[fx, fy, fz]
V001 = table[fx, fy, cz]
V010 = table[fx, cy, fz]
V011 = table[fx, cy, cz]
V100 = table[cx, fy, fz]
V101 = table[cx, fy, cz]
V110 = table[cx, cy, fz]
V111 = table[cx, cy, cz]
# Expand dimensions for broadcasting
x = x[:, np.newaxis]
y = y[:, np.newaxis]
z = z[:, np.newaxis]
# Tetrahedral interpolation - select tetrahedron based on position
xyz_o = np.select(
[
np.logical_and(x > y, y > z),
np.logical_and(x > z, z >= y),
np.logical_and(z >= x, x > y),
np.logical_and(y >= x, x > z),
np.logical_and(y >= z, z >= x),
np.logical_and(z > y, y >= x),
],
[
(1 - x) * V000 + (x - y) * V100 + (y - z) * V110 + z * V111,
(1 - x) * V000 + (x - z) * V100 + (z - y) * V101 + y * V111,
(1 - z) * V000 + (z - x) * V001 + (x - y) * V101 + y * V111,
(1 - y) * V000 + (y - x) * V010 + (x - z) * V110 + z * V111,
(1 - y) * V000 + (y - z) * V010 + (z - x) * V011 + x * V111,
(1 - z) * V000 + (z - y) * V001 + (y - x) * V011 + x * V111,
],
)
return xyz_o.reshape(original_shape)
TABLE_INTERPOLATION_METHODS = CanonicalMapping(
{
"Trilinear": table_interpolation_trilinear,
"Tetrahedral": table_interpolation_tetrahedral,
}
)
TABLE_INTERPOLATION_METHODS.__doc__ = """
Supported table interpolation methods.
References
----------
:cite:`Bourkeb`, :cite:`Kirk2006`
"""
[docs]
def table_interpolation(
V_xyz: ArrayLike,
table: ArrayLike,
method: Literal["Trilinear", "Tetrahedral"] | str = "Trilinear",
) -> NDArrayFloat:
"""
Perform interpolation of the specified :math:`V_{xyz}` values using a
4-dimensional interpolation table.
Interpolate the input :math:`V_{xyz}` values through either trilinear
or tetrahedral interpolation methods using the specified lookup table.
Parameters
----------
V_xyz
:math:`V_{xyz}` values to interpolate, where each row represents
a three-dimensional coordinate within the interpolation table's
domain.
table
4-dimensional (NxNxNx3) interpolation table defining the mapping
from input coordinates to output values.
method
Interpolation method to use. Either "Trilinear" for trilinear
interpolation or "Tetrahedral" for tetrahedral interpolation.
Returns
-------
:class:`numpy.ndarray`
Interpolated :math:`V_{xyz}` values with the same shape as the
input array.
References
----------
:cite:`Bourkeb`, :cite:`Kirk2006`
Examples
--------
>>> import os
>>> import colour
>>> path = os.path.join(
... os.path.dirname(__file__),
... "..",
... "io",
... "luts",
... "tests",
... "resources",
... "iridas_cube",
... "Colour_Correct.cube",
... )
>>> LUT = colour.read_LUT(path)
>>> table = LUT.table
>>> prng = np.random.RandomState(4)
>>> V_xyz = colour.algebra.random_triplet_generator(3, random_state=prng)
>>> print(V_xyz) # doctest: +ELLIPSIS
[[ 0.9670298... 0.7148159... 0.9762744...]
[ 0.5472322... 0.6977288... 0.0062302...]
[ 0.9726843... 0.2160895... 0.2529823...]]
>>> table_interpolation(V_xyz, table) # doctest: +ELLIPSIS
array([[ 1.0120664..., 0.7539146..., 1.0228540...],
[ 0.5075794..., 0.6479459..., 0.1066404...],
[ 1.0976519..., 0.1785998..., 0.2299897...]])
>>> table_interpolation(V_xyz, table, method="Tetrahedral")
... # doctest: +ELLIPSIS
array([[ 1.0196197..., 0.7674062..., 1.0311751...],
[ 0.5105603..., 0.6466722..., 0.1077296...],
[ 1.1178206..., 0.1762039..., 0.2209534...]])
"""
method = validate_method(method, tuple(TABLE_INTERPOLATION_METHODS))
return TABLE_INTERPOLATION_METHODS[method](V_xyz, table)