Coverage for algebra/interpolation.py: 69%

1"""

2 Interpolation

3=============

4

5 Provide classes and functions for interpolating variables in colour science

6 computations.

7

8 This module implements various interpolation methods for one-dimensional

9 functions and multi-dimensional table-based interpolation. These methods

10 support spectral data processing, colour transformations, and general

11 numerical interpolation tasks in colour science applications.

12

13- :class:`colour.KernelInterpolator`: 1-D function generic interpolation

14 with arbitrary kernel.

15- :class:`colour.NearestNeighbourInterpolator`: 1-D function

16 nearest-neighbour interpolation.

17- :class:`colour.LinearInterpolator`: 1-D function linear interpolation.

18- :class:`colour.SpragueInterpolator`: 1-D function fifth-order polynomial

19 interpolation using *Sprague (1880)* method.

20- :class:`colour.CubicSplineInterpolator`: 1-D function cubic spline

21 interpolation.

22- :class:`colour.PchipInterpolator`: 1-D function piecewise cube Hermite

23 interpolation.

24- :class:`colour.NullInterpolator`: 1-D function null interpolation.

25- :func:`colour.lagrange_coefficients`: Compute *Lagrange Coefficients*.

26- :func:`colour.algebra.table_interpolation_trilinear`: Perform trilinear

27 interpolation with table.

28- :func:`colour.algebra.table_interpolation_tetrahedral`: Perform

29 tetrahedral interpolation with table.

30- :attr:`colour.TABLE_INTERPOLATION_METHODS`: Supported table interpolation

31 methods.

32- :func:`colour.table_interpolation`: Perform interpolation with table using

33 specified method.

34

35 References

36----------

37- :cite:`Bourkeb` : Bourke, P. (n.d.). Trilinear Interpolation. Retrieved

38 January 13, 2018, from http://paulbourke.net/miscellaneous/interpolation/

39- :cite:`Burger2009b` : Burger, W., & Burge, M. J. (2009). Principles of

40 Digital Image Processing. Springer London. doi:10.1007/978-1-84800-195-4

41- :cite:`CIETC1-382005f` : CIE TC 1-38. (2005). 9.2.4 Method of

42 interpolation for uniformly spaced independent variable. In CIE 167:2005

43 Recommended Practice for Tabulating Spectral Data for Use in Colour

44 Computations (pp. 1-27). ISBN:978-3-901906-41-1

45- :cite:`CIETC1-382005h` : CIE TC 1-38. (2005). Table V. Values of the

46 c-coefficients of Equ.s 6 and 7. In CIE 167:2005 Recommended Practice for

47 Tabulating Spectral Data for Use in Colour Computations (p. 19).

48 ISBN:978-3-901906-41-1

49- :cite:`Fairman1985b` : Fairman, H. S. (1985). The calculation of weight

50 factors for tristimulus integration. Color Research & Application, 10(4),

51 199-203. doi:10.1002/col.5080100407

52- :cite:`Kirk2006` : Kirk, R. (2006). Truelight Software Library 2.0.

53 Retrieved July 8, 2017, from

54 https://www.filmlight.ltd.uk/pdf/whitepapers/FL-TL-TN-0057-SoftwareLib.pdf

55- :cite:`Westland2012h` : Westland, S., Ripamonti, C., & Cheung, V. (2012).

56 Interpolation Methods. In Computational Colour Science Using MATLAB (2nd

57 ed., pp. 29-37). ISBN:978-0-470-66569-5

58- :cite:`Wikipedia2003a` : Wikipedia. (2003). Lagrange polynomial -

59 Definition. Retrieved January 20, 2016, from

60 https://en.wikipedia.org/wiki/Lagrange_polynomial#Definition

61- :cite:`Wikipedia2005b` : Wikipedia. (2005). Lanczos resampling. Retrieved

62 October 14, 2017, from https://en.wikipedia.org/wiki/Lanczos_resampling

63"""

64

65 from __future__ import annotations

66

67 import sys

68 import typing

69 from functools import reduce

70 from unittest.mock import MagicMock

71

72 import numpy as np

73

74 from colour.utilities.requirements import is_scipy_installed

75 from colour.utilities.verbose import usage_warning

76

77 if not is_scipy_installed(): # pragma: no cover

78 try:

79 is_scipy_installed(raise_exception=True)

80 except ImportError as error:

81 usage_warning(str(error))

82

83 mock = MagicMock()

84 mock.__name__ = ""

85

86 for module in (

87 "scipy",

88 "scipy.interpolate",

89 ):

90 sys.modules[module] = mock

91

92 import scipy.interpolate

93

94 from colour.algebra import sdiv, sdiv_mode

95 from colour.constants import (

96 DTYPE_FLOAT_DEFAULT,

97 DTYPE_INT_DEFAULT,

98 TOLERANCE_ABSOLUTE_DEFAULT,

99 TOLERANCE_RELATIVE_DEFAULT,

100)

101

102 if typing.TYPE_CHECKING:

103 from colour.hints import (

104 Any,

105 ArrayLike,

106 Callable,

107 DTypeReal,

108 Literal,

109 Type,

110 )

111

112 from colour.hints import NDArrayFloat, cast

113 from colour.utilities import (

114 CanonicalMapping,

115 as_array,

116 as_float,

117 as_float_array,

118 as_float_scalar,

119 as_int_array,

120 attest,

121 closest_indexes,

122 interval,

123 is_numeric,

124 optional,

125 runtime_warning,

126 validate_method,

127)

128

129 __author__ = "Colour Developers"

130 __copyright__ = "Copyright 2013 Colour Developers"

131 __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"

132 __maintainer__ = "Colour Developers"

133 __email__ = "colour-developers@colour-science.org"

134 __status__ = "Production"

135

136 __all__ = [

137 "kernel_nearest_neighbour",

138 "kernel_linear",

139 "kernel_sinc",

140 "kernel_lanczos",

141 "kernel_cardinal_spline",

142 "KernelInterpolator",

143 "NearestNeighbourInterpolator",

144 "LinearInterpolator",

145 "SpragueInterpolator",

146 "CubicSplineInterpolator",

147 "PchipInterpolator",

148 "NullInterpolator",

149 "lagrange_coefficients",

150 "table_interpolation_trilinear",

151 "table_interpolation_tetrahedral",

152 "TABLE_INTERPOLATION_METHODS",

153 "table_interpolation",

154]

155

156

157 def kernel_nearest_neighbour(x: ArrayLike) -> NDArrayFloat:

158 """

159 Return the *nearest-neighbour* kernel evaluated at specified samples.

160

161 The *nearest-neighbour* kernel is a discontinuous kernel function that

162 equals 1 for samples within the range [-0.5, 0.5) and 0 elsewhere. This

163 kernel represents the simplest interpolation method where each output

164 value is determined by the closest input sample.

165

166 Parameters

167 ----------

168 x

169 Samples at which to evaluate the *nearest-neighbour* kernel.

170

171 Returns

172 -------

173 :class:`numpy.ndarray`

174 The *nearest-neighbour* kernel evaluated at specified samples.

175

176 References

177 ----------

178 :cite:`Burger2009b`

179

180 Examples

181 --------

182 >>> kernel_nearest_neighbour(np.linspace(0, 1, 10))

183 array([1, 1, 1, 1, 1, 0, 0, 0, 0, 0])

184 """

185

186 return np.where(np.abs(x) < 0.5, 1, 0)

187

188

189 def kernel_linear(x: ArrayLike) -> NDArrayFloat:

190 """

191 Evaluate the *linear* kernel at specified samples.

192

193 The *linear* kernel is a triangular function that returns 1 when

194 :math:`|x| < 0.5` and 0 otherwise, providing a simple binary response

195 based on the absolute value of the input.

196

197 Parameters

198 ----------

199 x

200 Samples at which to evaluate the *linear* kernel.

201

202 Returns

203 -------

204 :class:`numpy.ndarray`

205 The *linear* kernel evaluated at specified samples, with values of 1

206 for :math:`|x| < 0.5` and 0 otherwise.

207

208 References

209 ----------

210 :cite:`Burger2009b`

211

212 Examples

213 --------

214 >>> kernel_linear(np.linspace(0, 1, 10)) # doctest: +ELLIPSIS

215 array([ 1. , 0.8888888..., 0.7777777..., \

216 0.6666666..., 0.5555555...,

217 0.4444444..., 0.3333333..., 0.2222222..., \

218 0.1111111..., 0. ])

219 """

220

221 return np.where(np.abs(x) < 1, 1 - np.abs(x), 0)

222

223

224 def kernel_sinc(x: ArrayLike, a: float = 3) -> NDArrayFloat:

225 """

226 Evaluate the *sinc* kernel at specified sample positions.

227

228 Compute the *sinc* kernel function, commonly used in signal processing

229 and interpolation applications, for the specified sample positions.

230

231 Parameters

232 ----------

233 x

234 Sample positions at which to evaluate the *sinc* kernel.

235 a

236 Size parameter of the *sinc* kernel, controlling the function's

237 support width.

238

239 Returns

240 -------

241 :class:`numpy.ndarray`

242 *Sinc* kernel values evaluated at the specified sample positions.

243

244 Raises

245 ------

246 AssertionError

247 If ``a`` is less than 1.

248

249 References

250 ----------

251 :cite:`Burger2009b`

252

253 Examples

254 --------

255 >>> kernel_sinc(np.linspace(0, 1, 10)) # doctest: +ELLIPSIS

256 array([ 1.0000000...e+00, 9.7981553...e-01, 9.2072542...e-01,

257 8.2699334...e-01, 7.0531659...e-01, 5.6425327...e-01,

258 4.1349667...e-01, 2.6306440...e-01, 1.2247694...e-01,

259 3.8981718...e-17])

260 """

261

262 x = as_float_array(x)

263

264 attest(bool(a >= 1), '"a" must be equal or superior to 1!')

265

266 return np.where(np.abs(x) < a, np.sinc(x), 0)

267

268

269 def kernel_lanczos(x: ArrayLike, a: float = 3) -> NDArrayFloat:

270 """

271 Return the *Lanczos* kernel evaluated at specified samples.

272

273 The *Lanczos* kernel is a sinc-based windowing function commonly used

274 in signal processing and image resampling applications. It is defined

275 as :math:`L(x) = \\text{sinc}(x) \\cdot \\text{sinc}(x/a)` for

276 :math:`|x| < a`, and zero otherwise.

277

278 Parameters

279 ----------

280 x

281 Samples at which to evaluate the *Lanczos* kernel.

282 a

283 Size of the *Lanczos* kernel, defining the support region

284 :math:`[-a, a]`.

285

286 Returns

287 -------

288 :class:`numpy.ndarray`

289 The *Lanczos* kernel evaluated at specified samples.

290

291 References

292 ----------

293 :cite:`Wikipedia2005b`

294

295 Examples

296 --------

297 >>> kernel_lanczos(np.linspace(0, 1, 10)) # doctest: +ELLIPSIS

298 array([ 1.0000000...e+00, 9.7760615...e-01, 9.1243770...e-01,

299 8.1030092...e-01, 6.8012706...e-01, 5.3295773...e-01,

300 3.8071690...e-01, 2.3492839...e-01, 1.0554054...e-01,

301 3.2237621...e-17])

302 """

303

304 x = as_float_array(x)

305

306 attest(bool(a >= 1), '"a" must be equal or superior to 1!')

307

308 return np.where(np.abs(x) < a, np.sinc(x) * np.sinc(x / a), 0)

309

310

311 def kernel_cardinal_spline(

312 x: ArrayLike, a: float = 0.5, b: float = 0.0

313) -> NDArrayFloat:

314 """

315 Return the *cardinal spline* kernel evaluated at specified samples.

316

317 Notable *cardinal spline* :math:`a` and :math:`b` parameterizations:

318

319 - *Catmull-Rom*: :math:`(a=0.5, b=0)`

320 - *Cubic B-Spline*: :math:`(a=0, b=1)`

321 - *Mitchell-Netravalli*:

322 :math:`(a=\\cfrac{1}{3}, b=\\cfrac{1}{3})`

323

324 Parameters

325 ----------

326 x

327 Samples at which to evaluate the *cardinal spline* kernel.

328 a

329 :math:`a` control parameter.

330 b

331 :math:`b` control parameter.

332

333 Returns

334 -------

335 :class:`numpy.ndarray`

336 The *cardinal spline* kernel evaluated at specified samples.

337

338 References

339 ----------

340 :cite:`Burger2009b`

341

342 Examples

343 --------

344 >>> kernel_cardinal_spline(np.linspace(0, 1, 10)) # doctest: +ELLIPSIS

345 array([ 1. , 0.9711934..., 0.8930041..., \

346 0.7777777..., 0.6378600...,

347 0.4855967..., 0.3333333..., 0.1934156..., \

348 0.0781893..., 0. ])

349 """

350

351 x = as_float_array(x)

352

353 x_abs = np.abs(x)

354 y = np.where(

355 x_abs < 1,

356 (-6 * a - 9 * b + 12) * x_abs**3 + (6 * a + 12 * b - 18) * x_abs**2 - 2 * b + 6,

357 (-6 * a - b) * x_abs**3

358 + (30 * a + 6 * b) * x_abs**2

359 + (-48 * a - 12 * b) * x_abs

360 + 24 * a

361 + 8 * b,

362 )

363 y[x_abs >= 2] = 0

364

365 return 1 / 6 * y

366

367

368 class KernelInterpolator:

369 """

370 Perform kernel-based interpolation of a 1-D function.

371

372 Reconstruct a continuous signal from discrete samples using linear

373 convolution. Express interpolation as the convolution of the specified

374 discrete function :math:`g(x)` with a continuous interpolation kernel

375 :math:`k(w)`:

376

377 :math:`\\hat{g}(w_0) = [k * g](w_0) = \

378\\sum_{x=-\\infty}^{\\infty}k(w_0 - x)\\cdot g(x)`

379

380 Parameters

381 ----------

382 x

383 Independent :math:`x` variable values corresponding with :math:`y`

384 variable.

385 y

386 Dependent and already known :math:`y` variable values to interpolate.

387 window

388 Width of the window in samples on each side.

389 kernel

390 Kernel to use for interpolation.

391 kernel_kwargs

392 Arguments to use when calling the kernel.

393 padding_kwargs

394 Arguments to use when padding :math:`y` variable values with the

395 :func:`np.pad` definition.

396 dtype

397 Data type used for internal conversions.

398

399 Attributes

400 ----------

401 - :attr:`~colour.KernelInterpolator.x`

402 - :attr:`~colour.KernelInterpolator.y`

403 - :attr:`~colour.KernelInterpolator.window`

404 - :attr:`~colour.KernelInterpolator.kernel`

405 - :attr:`~colour.KernelInterpolator.kernel_kwargs`

406 - :attr:`~colour.KernelInterpolator.padding_kwargs`

407

408 Methods

409 -------

410 - :meth:`~colour.KernelInterpolator.__init__`

411 - :meth:`~colour.KernelInterpolator.__call__`

412

413 References

414 ----------

415 :cite:`Burger2009b`, :cite:`Wikipedia2005b`

416

417 Examples

418 --------

419 Interpolating a single numeric variable:

420

421 >>> y = np.array(

422 ... [5.9200, 9.3700, 10.8135, 4.5100, 69.5900, 27.8007, 86.0500]

423 ... )

424 >>> x = np.arange(len(y))

425 >>> f = KernelInterpolator(x, y)

426 >>> f(0.5) # doctest: +ELLIPSIS

427 6.9411400...

428

429 Interpolating an `ArrayLike` variable:

430

431 >>> f([0.25, 0.75]) # doctest: +ELLIPSIS

432 array([ 6.1806208..., 8.0823848...])

433

434 Using a different *lanczos* kernel:

435

436 >>> f = KernelInterpolator(x, y, kernel=kernel_sinc)

437 >>> f([0.25, 0.75]) # doctest: +ELLIPSIS

438 array([ 6.5147317..., 8.3965466...])

439

440 Using a different window size:

441

442 >>> f = KernelInterpolator(

443 ... x, y, window=16, kernel=kernel_lanczos, kernel_kwargs={"a": 16}

444 ... )

445 >>> f([0.25, 0.75]) # doctest: +ELLIPSIS

446 array([ 5.3961792..., 5.6521093...])

447 """

448

449 def __init__(

450 self,

451 x: ArrayLike,

452 y: ArrayLike,

453 window: float = 3,

454 kernel: Callable = kernel_lanczos,

455 kernel_kwargs: dict | None = None,

456 padding_kwargs: dict | None = None,

457 dtype: Type[DTypeReal] | None = None,

458 *args: Any, # noqa: ARG002

459 **kwargs: Any, # noqa: ARG002

460 ) -> None:

461 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

462

463 self._x_p: NDArrayFloat = np.array([])

464 self._y_p: NDArrayFloat = np.array([])

465

466 self._x: NDArrayFloat = np.array([])

467 self._y: NDArrayFloat = np.array([])

468 self._window: float = 3

469 self._padding_kwargs: dict = {

470 "pad_width": (window, window),

471 "mode": "reflect",

472 }

473 self._kernel: Callable = kernel_lanczos

474 self._kernel_kwargs: dict = {}

475 self._dtype: Type[DTypeReal] = dtype

476

477 self.x = x

478 self.y = y

479 self.window = window

480 self.padding_kwargs = optional(padding_kwargs, self._padding_kwargs)

481 self.kernel = kernel

482 self.kernel_kwargs = optional(kernel_kwargs, self._kernel_kwargs)

483

484 self._validate_dimensions()

485

486 @property

487 def x(self) -> NDArrayFloat:

488 """

489 Getter and setter for the independent :math:`x` variable.

490

491 Parameters

492 ----------

493 value

494 Value to set the independent :math:`x` variable with.

495

496 Returns

497 -------

498 :class:`numpy.ndarray`

499 Independent :math:`x` variable.

500 """

501

502 return self._x

503

504 @x.setter

505 def x(self, value: ArrayLike) -> None:

506 """Setter for the **self.x** property."""

507

508 value = np.atleast_1d(value).astype(self._dtype)

509

510 attest(

511 value.ndim == 1,

512 '"x" independent variable must have exactly one dimension!',

513 )

514

515 value_interval = interval(value)

516

517 if value_interval.size != 1:

518 runtime_warning(

519 '"x" independent variable is not uniform, '

520 "unpredictable results may occur!"

521 )

522

523 self._x = as_array(value, self._dtype)

524

525 self._x_p = np.pad(

526 self._x,

527 as_int_array([self._window, self._window]),

528 "linear_ramp",

529 end_values=(

530 np.min(self._x) - self._window * value_interval[0],

531 np.max(self._x) + self._window * value_interval[0],

532 ),

533 )

534

535 @property

536 def y(self) -> NDArrayFloat:

537 """

538 Getter and setter for the dependent and already known :math:`y` variable.

539

540 Parameters

541 ----------

542 value

543 Value to set the dependent and already known :math:`y` variable

544 with.

545

546 Returns

547 -------

548 :class:`numpy.ndarray`

549 Dependent and already known :math:`y` variable.

550 """

551

552 return self._y

553

554 @y.setter

555 def y(self, value: ArrayLike) -> None:

556 """Setter for the **self.y** property."""

557

558 value = np.atleast_1d(value).astype(self._dtype)

559

560 attest(

561 value.ndim == 1,

562 '"y" dependent variable must have exactly one dimension!',

563 )

564

565 self._y = as_array(value, self._dtype)

566

567 if self._window is not None:

568 self._y_p = np.pad(self._y, **self._padding_kwargs)

569

570 @property

571 def window(self) -> float:

572 """

573 Getter and setter for the filtering window size for the moving average.

574

575 The window determines the number of samples used in the moving

576 average calculation. A larger window produces smoother results

577 with greater lag, while a smaller window yields more responsive

578 but potentially noisier output.

579

580 Parameters

581 ----------

582 value

583 Value to set the window with.

584

585 Returns

586 -------

587 :class:`float`

588 Window size for the moving average filter.

589 """

590

591 return self._window

592

593 @window.setter

594 def window(self, value: float) -> None:

595 """Setter for the **self.window** property."""

596

597 attest(bool(value >= 1), '"window" must be equal to or greater than 1!')

598

599 self._window = value

600

601 # Triggering "self._x_p" update.

602 if self._x is not None:

603 self.x = self._x

604

605 # Triggering "self._y_p" update.

606 if self._y is not None:

607 self.y = self._y

608

609 @property

610 def kernel(self) -> Callable:

611 """

612 Getter and setter for the kernel callable for the interpolator.

613

614 Parameters

615 ----------

616 value

617 Value to set the callable object to use as the interpolation kernel

618 with. Must be a callable that accepts numeric arguments.

619

620 Returns

621 -------

622 Callable

623 Callable object to use as the interpolation kernel.

624

625 Raises

626 ------

627 AssertionError

628 If the provided value is not callable.

629 """

630

631 return self._kernel

632

633 @kernel.setter

634 def kernel(self, value: Callable) -> None:

635 """Setter for the **self.kernel** property."""

636

637 attest(

638 callable(value),

639 f'"kernel" property: "{value}" is not callable!',

640 )

641

642 self._kernel = value

643

644 @property

645 def kernel_kwargs(self) -> dict:

646 """

647 Getter and setter for the kernel keyword arguments for the convolution

648 operation.

649

650 Parameters

651 ----------

652 value

653 Value to set the keyword arguments to pass to the kernel function

654 with.

655

656 Returns

657 -------

658 :class:`dict`

659 Keyword arguments to pass to the kernel function.

660

661 Raises

662 ------

663 AssertionError

664 If the provided value is not a :class:'dict` class instance.

665 """

666

667 return self._kernel_kwargs

668

669 @kernel_kwargs.setter

670 def kernel_kwargs(self, value: dict) -> None:

671 """Setter for the **self.kernel_kwargs** property."""

672

673 attest(

674 isinstance(value, dict),

675 f'"kernel_kwargs" property: "{value}" type is not "dict"!',

676 )

677

678 self._kernel_kwargs = value

679

680 @property

681 def padding_kwargs(self) -> dict:

682 """

683 Getter and setter for the padding keyword arguments for edge handling.

684

685 Parameters

686 ----------

687 value

688 Value to set the keyword arguments to pass to the padding function

689 when handling edges during interpolation.

690

691 Returns

692 -------

693 :class:`dict`

694 Keyword arguments to pass to the padding function when handling

695 edges during interpolation.

696

697 Raises

698 ------

699 AssertionError

700 If the provided value is not a :class:`dict` class instance.

701 """

702

703 return self._padding_kwargs

704

705 @padding_kwargs.setter

706 def padding_kwargs(self, value: dict) -> None:

707 """Setter for the **self.padding_kwargs** property."""

708

709 attest(

710 isinstance(value, dict),

711 f'"padding_kwargs" property: "{value}" type is not a "dict" instance!',

712 )

713

714 self._padding_kwargs = value

715

716 # Triggering "self._y_p" update.

717 if self._y is not None:

718 self.y = self._y

719

720 def __call__(self, x: ArrayLike) -> NDArrayFloat:

721 """

722 Evaluate the interpolator at specified point(s).

723

724 Parameters

725 ----------

726 x

727 Point(s) to evaluate the interpolant at.

728

729 Returns

730 -------

731 :class:`numpy.ndarray`

732 Interpolated value(s).

733 """

734

735 x = as_float_array(x)

736

737 xi = self._evaluate(x)

738

739 return as_float(xi)

740

741 def _evaluate(self, x: NDArrayFloat) -> NDArrayFloat:

742 """

743 Evaluate the interpolating polynomial at the specified point.

744

745 Parameters

746 ----------

747 x

748 Point at which to evaluate the interpolant.

749

750 Returns

751 -------

752 :class:`numpy.ndarray`

753 Interpolated values at the specified point.

754 """

755

756 self._validate_dimensions()

757 self._validate_interpolation_range(x)

758

759 x_interval = interval(self._x)[0]

760 x_f = np.floor(x / x_interval)

761

762 windows = x_f[..., None] + np.arange(-self._window + 1, self._window + 1)

763 clip_l = min(self._x_p) / x_interval

764 clip_h = max(self._x_p) / x_interval

765 windows = np.clip(windows, clip_l, clip_h) - clip_l

766 windows = as_int_array(np.around(windows))

767

768 return np.sum(

769 self._y_p[windows]

770 * self._kernel(

771 x[..., None] / x_interval - windows - min(self._x_p) / x_interval,

772 **self._kernel_kwargs,

773 ),

774 axis=-1,

775 )

776

777 def _validate_dimensions(self) -> None:

778 """

779 Validate that the dimensions of the variables are equal.

780

781 Raises

782 ------

783 ValueError

784 If the x and y variable dimensions do not match.

785 """

786

787 if len(self._x) != len(self._y):

788 error = (

789 '"x" independent and "y" dependent variables have different '

790 f'dimensions: "{len(self._x)}", "{len(self._y)}"'

791 )

792

793 raise ValueError(error)

794

795 def _validate_interpolation_range(self, x: NDArrayFloat) -> None:

796 """

797 Validate that the specified interpolation point is within the valid

798 interpolation range.

799

800 The interpolation point must be within the bounds defined by the first

801 and last x-coordinates of the interpolator's data.

802

803 Parameters

804 ----------

805 x

806 Point to validate for interpolation range compliance.

807

808 Raises

809 ------

810 ValueError

811 If the point is outside the valid interpolation range.

812 """

813

814 below_interpolation_range = x < self._x[0]

815 above_interpolation_range = x > self._x[-1]

816

817 if below_interpolation_range.any():

818 error = f'"{x}" is below interpolation range.'

819

820 raise ValueError(error)

821

822 if above_interpolation_range.any():

823 error = f'"{x}" is above interpolation range.'

824

825 raise ValueError(error)

826

827

828 class NearestNeighbourInterpolator(KernelInterpolator):

829 """

830 Perform nearest-neighbour interpolation on discrete data.

831

832 Implement a kernel-based interpolator that selects the closest known

833 data point for each query position. This interpolator provides fast,

834 discontinuous interpolation suitable for categorical data or when

835 preserving exact measured values is required.

836

837 Other Parameters

838 ----------------

839 dtype

840 Data type used for internal conversions.

841 padding_kwargs

842 Arguments to use when padding :math:`y` variable values with the

843 :func:`np.pad` definition.

844 window

845 Width of the window in samples on each side.

846 x

847 Independent :math:`x` variable values corresponding with :math:`y`

848 variable.

849 y

850 Dependent and already known :math:`y` variable values to

851 interpolate.

852

853 Methods

854 -------

855 - :meth:`~colour.NearestNeighbourInterpolator.__init__`

856 """

857

858 def __init__(self, *args: Any, **kwargs: Any) -> None:

859 kwargs["kernel"] = kernel_nearest_neighbour

860 kwargs.pop("kernel_kwargs", None)

861

862 super().__init__(*args, **kwargs)

863

864

865 class LinearInterpolator:

866 """

867 Perform linear interpolation of a 1-D function.

868

869 This class provides a wrapper around NumPy's linear interpolation

870 functionality for interpolating between specified data points.

871

872 Parameters

873 ----------

874 x

875 Independent :math:`x` variable values corresponding with :math:`y`

876 variable.

877 y

878 Dependent and already known :math:`y` variable values to

879 interpolate.

880 dtype

881 Data type used for internal conversions.

882

883 Attributes

884 ----------

885 - :attr:`~colour.LinearInterpolator.x`

886 - :attr:`~colour.LinearInterpolator.y`

887

888 Methods

889 -------

890 - :meth:`~colour.LinearInterpolator.__init__`

891 - :meth:`~colour.LinearInterpolator.__call__`

892

893 Notes

894 -----

895 - This class is a wrapper around *numpy.interp* definition.

896

897 Examples

898 --------

899 Interpolating a single numeric variable:

900

901 >>> y = np.array([5.9200, 9.3700, 10.8135, 4.5100, 69.5900, 27.8007, 86.0500])

902 >>> x = np.arange(len(y))

903 >>> f = LinearInterpolator(x, y)

904 >>> f(0.5) # doctest: +ELLIPSIS

905 7.64...

906

907 Interpolating an `ArrayLike` variable:

908

909 >>> f([0.25, 0.75])

910 array([ 6.7825, 8.5075])

911 """

912

913 def __init__(

914 self,

915 x: ArrayLike,

916 y: ArrayLike,

917 dtype: Type[DTypeReal] | None = None,

918 *args: Any, # noqa: ARG002

919 **kwargs: Any, # noqa: ARG002

920 ) -> None:

921 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

922

923 self._x: NDArrayFloat = np.array([])

924 self._y: NDArrayFloat = np.array([])

925 self._dtype: Type[DTypeReal] = dtype

926

927 self.x = x

928 self.y = y

929

930 self._validate_dimensions()

931

932 @property

933 def x(self) -> NDArrayFloat:

934 """

935 Getter and setter for the independent :math:`x` variable.

936

937 Parameters

938 ----------

939 value

940 Value to set the independent :math:`x` variable with.

941

942 Returns

943 -------

944 :class:`numpy.ndarray`

945 Independent :math:`x` variable.

946

947 Raises

948 ------

949 AssertionError

950 If the provided value has not exactly one dimension.

951 """

952

953 return self._x

954

955 @x.setter

956 def x(self, value: ArrayLike) -> None:

957 """Setter for the **self.x** property."""

958

959 value = cast("NDArrayFloat", np.atleast_1d(value).astype(self._dtype))

960

961 attest(

962 value.ndim == 1,

963 '"x" independent variable must have exactly one dimension!',

964 )

965

966 self._x = value

967

968 @property

969 def y(self) -> NDArrayFloat:

970 """

971 Getter and setter for the dependent and already known

972 :math:`y` variable.

973

974 Parameters

975 ----------

976 value

977 Value to set the dependent and already known :math:`y` variable

978 with.

979

980 Returns

981 -------

982 :class:`numpy.ndarray`

983 Dependent and already known :math:`y` variable.

984

985 Raises

986 ------

987 AssertionError

988 If the provided value has not exactly one dimension.

989 """

990

991 return self._y

992

993 @y.setter

994 def y(self, value: ArrayLike) -> None:

995 """Setter for the **self.y** property."""

996

997 value = cast("NDArrayFloat", np.atleast_1d(value).astype(self._dtype))

998

999 attest(

1000 value.ndim == 1,

1001 '"y" dependent variable must have exactly one dimension!',

1002 )

1003

1004 self._y = value

1005

1006 def __call__(self, x: ArrayLike) -> NDArrayFloat:

1007 """

1008 Evaluate the interpolating polynomial at specified point(s).

1009

1010

1011 Parameters

1012 ----------

1013 x

1014 Point(s) to evaluate the interpolant at.

1015

1016 Returns

1017 -------

1018 :class:`numpy.ndarray`

1019 Interpolated value(s).

1020 """

1021

1022 x = as_float_array(x)

1023

1024 xi = self._evaluate(x)

1025

1026 return as_float(xi)

1027

1028 def _evaluate(self, x: NDArrayFloat) -> NDArrayFloat:

1029 """

1030 Perform the interpolating polynomial evaluation at specified points.

1031

1032 Parameters

1033 ----------

1034 x

1035 Points to evaluate the interpolant at.

1036

1037 Returns

1038 -------

1039 :class:`numpy.ndarray`

1040 Interpolated points values.

1041 """

1042

1043 self._validate_dimensions()

1044 self._validate_interpolation_range(x)

1045

1046 return np.interp(x, self._x, self._y)

1047

1048 def _validate_dimensions(self) -> None:

1049 """Validate that the variables dimensions are the same."""

1050

1051 if len(self._x) != len(self._y):

1052 error = (

1053 '"x" independent and "y" dependent variables have different '

1054 f'dimensions: "{len(self._x)}", "{len(self._y)}"'

1055 )

1056

1057 raise ValueError(error)

1058

1059 def _validate_interpolation_range(self, x: NDArrayFloat) -> None:

1060 """Validate specified point to be in interpolation range."""

1061

1062 below_interpolation_range = x < self._x[0]

1063 above_interpolation_range = x > self._x[-1]

1064

1065 if below_interpolation_range.any():

1066 error = f'"{x}" is below interpolation range.'

1067

1068 raise ValueError(error)

1069

1070 if above_interpolation_range.any():

1071 error = f'"{x}" is above interpolation range.'

1072

1073 raise ValueError(error)

1074

1075

1076 class SpragueInterpolator:

1077 """

1078 Perform fifth-order polynomial interpolation using the *Sprague (1880)*

1079 method for uniformly spaced data.

1080

1081 Implement the *Sprague (1880)* interpolation method recommended by the

1082 *CIE* for interpolating functions with uniformly spaced independent

1083 variables. This interpolator constructs a fifth-order polynomial that

1084 passes through specified dependent variable values, providing smooth

1085 interpolation suitable for spectral data and other colour science

1086 applications.

1087

1088 Parameters

1089 ----------

1090 x

1091 Independent :math:`x` variable values corresponding with :math:`y`

1092 variable.

1093 y

1094 Dependent and already known :math:`y` variable values to

1095 interpolate.

1096 dtype

1097 Data type used for internal conversions.

1098

1099 Attributes

1100 ----------

1101 - :attr:`~colour.SpragueInterpolator.x`

1102 - :attr:`~colour.SpragueInterpolator.y`

1103

1104 Methods

1105 -------

1106 - :meth:`~colour.SpragueInterpolator.__init__`

1107 - :meth:`~colour.SpragueInterpolator.__call__`

1108

1109 Notes

1110 -----

1111 - The minimum number :math:`k` of data points required along the

1112 interpolation axis is :math:`k=6`.

1113

1114 References

1115 ----------

1116 :cite:`CIETC1-382005f`, :cite:`Westland2012h`

1117

1118 Examples

1119 --------

1120 Interpolating a single numeric variable:

1121

1122 >>> y = np.array([5.9200, 9.3700, 10.8135, 4.5100, 69.5900, 27.8007, 86.0500])

1123 >>> x = np.arange(len(y))

1124 >>> f = SpragueInterpolator(x, y)

1125 >>> f(0.5) # doctest: +ELLIPSIS

1126 7.2185025...

1127

1128 Interpolating an `ArrayLike` variable:

1129

1130 >>> f([0.25, 0.75]) # doctest: +ELLIPSIS

1131 array([ 6.7295161..., 7.8140625...])

1132 """

1133

1134 SPRAGUE_C_COEFFICIENTS = np.array(

1135 [

1136 [884, -1960, 3033, -2648, 1080, -180],

1137 [508, -540, 488, -367, 144, -24],

1138 [-24, 144, -367, 488, -540, 508],

1139 [-180, 1080, -2648, 3033, -1960, 884],

1140 ]

1141 )

1142 """

1143 Defines the coefficients used to generate extra points for boundaries

1144 interpolation.

1145

1146 SPRAGUE_C_COEFFICIENTS, (4, 6)

1147

1148 References

1149 ----------

1150 :cite:`CIETC1-382005h`

1151 """

1152

1153 def __init__(

1154 self,

1155 x: ArrayLike,

1156 y: ArrayLike,

1157 dtype: Type[DTypeReal] | None = None,

1158 *args: Any, # noqa: ARG002

1159 **kwargs: Any, # noqa: ARG002

1160 ) -> None:

1161 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1162

1163 self._xp: NDArrayFloat = np.array([])

1164 self._yp: NDArrayFloat = np.array([])

1165

1166 self._x: NDArrayFloat = np.array([])

1167 self._y: NDArrayFloat = np.array([])

1168 self._dtype: Type[DTypeReal] = dtype

1169

1170 self.x = x

1171 self.y = y

1172

1173 self._validate_dimensions()

1174

1175 @property

1176 def x(self) -> NDArrayFloat:

1177 """

1178 Getter and setter for the independent :math:`x` variable.

1179

1180 Parameters

1181 ----------

1182 value

1183 Value to set the independent :math:`x` variable with.

1184

1185 Returns

1186 -------

1187 :class:`numpy.ndarray`

1188 Independent :math:`x` variable.

1189

1190 Raises

1191 ------

1192 AssertionError

1193 If the provided value has not exactly one dimension.

1194 """

1195

1196 return self._x

1197

1198 @x.setter

1199 def x(self, value: ArrayLike) -> None:

1200 """Setter for the **self.x** property."""

1201

1202 value = as_array(np.atleast_1d(value), self._dtype)

1203

1204 attest(

1205 value.ndim == 1,

1206 '"x" independent variable must have exactly one dimension!',

1207 )

1208

1209 self._x = value

1210

1211 value_interval = interval(self._x)[0]

1212

1213 xp1 = self._x[0] - value_interval * 2

1214 xp2 = self._x[0] - value_interval

1215 xp3 = self._x[-1] + value_interval

1216 xp4 = self._x[-1] + value_interval * 2

1217

1218 self._xp = np.concatenate(

1219 [

1220 as_array([xp1, xp2], self._dtype),

1221 value,

1222 as_array([xp3, xp4], self._dtype),

1223 ]

1224 )

1225

1226 @property

1227 def y(self) -> NDArrayFloat:

1228 """

1229 Getter and setter for the dependent and already known

1230 :math:`y` variable.

1231

1232 Parameters

1233 ----------

1234 value

1235 Value to set the dependent and already known :math:`y` variable

1236 with.

1237

1238 Returns

1239 -------

1240 :class:`numpy.ndarray`

1241 Dependent and already known :math:`y` variable.

1242

1243 Raises

1244 ------

1245 AssertionError

1246 If the provided value has not exactly one dimension and its value

1247 count is less than 6.

1248 """

1249

1250 return self._y

1251

1252 @y.setter

1253 def y(self, value: ArrayLike) -> None:

1254 """Setter for the **self.y** property."""

1255

1256 value = as_array(np.atleast_1d(value), self._dtype)

1257

1258 attest(

1259 value.ndim == 1,

1260 '"y" dependent variable must have exactly one dimension!',

1261 )

1262

1263 attest(

1264 len(value) >= 6,

1265 '"y" dependent variable values count must be equal to or greater than 6!',

1266 )

1267

1268 self._y = value

1269

1270 yp1, yp2, yp3, yp4 = (

1271 np.sum(

1272 self.SPRAGUE_C_COEFFICIENTS

1273 * np.asarray((value[0:6], value[0:6], value[-6:], value[-6:])),

1274 axis=1,

1275 )

1276 / 209

1277 )

1278

1279 self._yp = np.concatenate(

1280 [

1281 as_array([yp1, yp2], self._dtype),

1282 value,

1283 as_array([yp3, yp4], self._dtype),

1284 ]

1285 )

1286

1287 def __call__(self, x: ArrayLike) -> NDArrayFloat:

1288 """

1289 Evaluate the interpolating polynomial at specified point(s).

1290

1291 Parameters

1292 ----------

1293 x

1294 Point(s) to evaluate the interpolant at.

1295

1296 Returns

1297 -------

1298 :class:`numpy.ndarray`

1299 Interpolated value(s).

1300 """

1301

1302 x = as_float_array(x)

1303

1304 xi = self._evaluate(x)

1305

1306 return as_float(xi)

1307

1308 def _evaluate(self, x: NDArrayFloat) -> NDArrayFloat:

1309 """

1310 Perform the interpolating polynomial evaluation at specified point.

1311

1312 Parameters

1313 ----------

1314 x

1315 Point to evaluate the interpolant at.

1316

1317 Returns

1318 -------

1319 :class:`numpy.ndarray`

1320 Interpolated point values.

1321 """

1322

1323 self._validate_dimensions()

1324 self._validate_interpolation_range(x)

1325

1326 i = np.searchsorted(self._xp, x) - 1

1327 with sdiv_mode():

1328 X = sdiv(x - self._xp[i], self._xp[i + 1] - self._xp[i])

1329

1330 r = self._yp

1331

1332 r_s = np.asarray((r[i - 2], r[i - 1], r[i], r[i + 1], r[i + 2], r[i + 3]))

1333 w_s = np.asarray(

1334 (

1335 (2, -16, 0, 16, -2, 0),

1336 (-1, 16, -30, 16, -1, 0),

1337 (-9, 39, -70, 66, -33, 7),

1338 (13, -64, 126, -124, 61, -12),

1339 (-5, 25, -50, 50, -25, 5),

1340 )

1341 )

1342 a = np.dot(w_s, r_s) / 24

1343

1344 # Fancy vector code here... use underlying numpy structures to accelerate

1345 # parts of the linear algebra.

1346

1347 y = r[i] + (a.reshape(5, -1) * X ** np.arange(1, 6).reshape(-1, 1)).sum(axis=0)

1348

1349 if y.size == 1:

1350 return y[0]

1351

1352 return y

1353

1354 def _validate_dimensions(self) -> None:

1355 """Validate that the variables dimensions are the same."""

1356

1357 if len(self._x) != len(self._y):

1358 error = (

1359 '"x" independent and "y" dependent variables have different '

1360 f'dimensions: "{len(self._x)}", "{len(self._y)}"'

1361 )

1362

1363 raise ValueError(error)

1364

1365 def _validate_interpolation_range(self, x: NDArrayFloat) -> None:

1366 """Validate specified point to be in interpolation range."""

1367

1368 below_interpolation_range = x < self._x[0]

1369 above_interpolation_range = x > self._x[-1]

1370

1371 if below_interpolation_range.any():

1372 error = f'"{x}" is below interpolation range.'

1373

1374 raise ValueError(error)

1375

1376 if above_interpolation_range.any():

1377 error = f'"{x}" is above interpolation range.'

1378

1379 raise ValueError(error)

1380

1381

1382 class CubicSplineInterpolator(scipy.interpolate.interp1d):

1383 """

1384 Perform cubic spline interpolation on one-dimensional data.

1385

1386 Provide smooth interpolation through specified data points using

1387 piecewise cubic polynomials. The resulting interpolant maintains

1388 continuity in the function and its first two derivatives at data

1389 points, making it suitable for spectral data and colour science

1390 applications requiring smooth transitions between measured values.

1391

1392 Methods

1393 -------

1394 - :meth:`~colour.CubicSplineInterpolator.__init__`

1395

1396 Notes

1397 -----

1398 - This class is a wrapper around *scipy.interpolate.interp1d* class.

1399 """

1400

1401 def __init__(self, *args: Any, **kwargs: Any) -> None:

1402 kwargs["kind"] = "cubic"

1403 super().__init__(*args, **kwargs)

1404

1405

1406 class PchipInterpolator(scipy.interpolate.PchipInterpolator):

1407 """

1408 Interpolate a 1-D function using Piecewise Cubic Hermite Interpolating

1409 Polynomial (PCHIP) interpolation.

1410

1411 PCHIP interpolation constructs a smooth curve through specified data

1412 points while preserving monotonicity between consecutive points. This

1413 method ensures that the interpolated values do not exhibit spurious

1414 oscillations, making it particularly suitable for colour science

1415 applications where physical constraints must be respected.

1416

1417 Attributes

1418 ----------

1419 - :attr:`~colour.PchipInterpolator.y`

1420

1421 Methods

1422 -------

1423 - :meth:`~colour.PchipInterpolator.__init__`

1424

1425 Notes

1426 -----

1427 - This class is a wrapper around *scipy.interpolate.PchipInterpolator*

1428 class.

1429 """

1430

1431 def __init__(self, x: ArrayLike, y: ArrayLike, *args: Any, **kwargs: Any) -> None:

1432 super().__init__(as_float_array(x), as_float_array(y), *args, **kwargs)

1433

1434 self._y: NDArrayFloat = as_float_array(y)

1435

1436 @property

1437 def y(self) -> NDArrayFloat:

1438 """

1439 Getter and setter for the dependent and already known

1440 :math:`y` variable.

1441

1442 Parameters

1443 ----------

1444 value

1445 Value to set the dependent and already known :math:`y` variable

1446 with.

1447

1448 Returns

1449 -------

1450 :class:`numpy.ndarray`

1451 Dependent and already known :math:`y` variable.

1452 """

1453

1454 return self._y

1455

1456 @y.setter

1457 def y(self, value: ArrayLike) -> None:

1458 """Setter for the **self.y** property."""

1459

1460 self._y = as_float_array(value)

1461

1462

1463 class NullInterpolator:

1464 """

1465 Implement 1-D function null interpolation.

1466

1467 This interpolator returns existing :math:`y` values when called with

1468 :math:`x` values within specified tolerances, and returns a default

1469 value when outside tolerances. Unlike traditional interpolators that

1470 estimate intermediate values, this null interpolator only returns exact

1471 matches within tolerance bounds.

1472

1473 Parameters

1474 ----------

1475 x

1476 Independent :math:`x` variable values corresponding with :math:`y`

1477 variable.

1478 y

1479 Dependent and already known :math:`y` variable values to

1480 interpolate.

1481 absolute_tolerance

1482 Absolute tolerance.

1483 relative_tolerance

1484 Relative tolerance.

1485 default

1486 Default value for interpolation outside tolerances.

1487 dtype

1488 Data type used for internal conversions.

1489

1490 Attributes

1491 ----------

1492 - :attr:`~colour.NullInterpolator.x`

1493 - :attr:`~colour.NullInterpolator.y`

1494 - :attr:`~colour.NullInterpolator.relative_tolerance`

1495 - :attr:`~colour.NullInterpolator.absolute_tolerance`

1496 - :attr:`~colour.NullInterpolator.default`

1497

1498 Methods

1499 -------

1500 - :meth:`~colour.NullInterpolator.__init__`

1501 - :meth:`~colour.NullInterpolator.__call__`

1502

1503 Examples

1504 --------

1505 >>> y = np.array([5.9200, 9.3700, 10.8135, 4.5100, 69.5900, 27.8007, 86.0500])

1506 >>> x = np.arange(len(y))

1507 >>> f = NullInterpolator(x, y)

1508 >>> f(0.5)

1509 nan

1510 >>> f(1.0) # doctest: +ELLIPSIS

1511 9.3699999...

1512 >>> f = NullInterpolator(x, y, absolute_tolerance=0.01)

1513 >>> f(1.01) # doctest: +ELLIPSIS

1514 9.3699999...

1515 """

1516

1517 def __init__(

1518 self,

1519 x: ArrayLike,

1520 y: ArrayLike,

1521 absolute_tolerance: float = TOLERANCE_ABSOLUTE_DEFAULT,

1522 relative_tolerance: float = TOLERANCE_RELATIVE_DEFAULT,

1523 default: float = np.nan,

1524 dtype: Type[DTypeReal] | None = None,

1525 *args: Any, # noqa: ARG002

1526 **kwargs: Any, # noqa: ARG002

1527 ) -> None:

1528 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1529

1530 self._x: NDArrayFloat = np.array([])

1531 self._y: NDArrayFloat = np.array([])

1532 self._absolute_tolerance: float = TOLERANCE_ABSOLUTE_DEFAULT

1533 self._relative_tolerance: float = TOLERANCE_RELATIVE_DEFAULT

1534 self._default: float = np.nan

1535 self._dtype: Type[DTypeReal] = dtype

1536

1537 self.x = x

1538 self.y = y

1539 self.absolute_tolerance = absolute_tolerance

1540 self.relative_tolerance = relative_tolerance

1541 self.default = default

1542

1543 self._validate_dimensions()

1544

1545 @property

1546 def x(self) -> NDArrayFloat:

1547 """

1548 Getter and setter for the independent :math:`x` variable.

1549

1550 Parameters

1551 ----------

1552 value

1553 Value to set the independent :math:`x` variable with.

1554

1555 Returns

1556 -------

1557 :class:`numpy.ndarray`

1558 Independent :math:`x` variable.

1559

1560 Raises

1561 ------

1562 AssertionError

1563 If the provided value has not exactly one dimension.

1564 """

1565

1566 return self._x

1567

1568 @x.setter

1569 def x(self, value: ArrayLike) -> None:

1570 """Setter for the **self.x** property."""

1571

1572 value = cast("NDArrayFloat", np.atleast_1d(value).astype(self._dtype))

1573

1574 attest(

1575 value.ndim == 1,

1576 '"x" independent variable must have exactly one dimension!',

1577 )

1578

1579 self._x = value

1580

1581 @property

1582 def y(self) -> NDArrayFloat:

1583 """

1584 Getter and setter for the dependent and already known

1585 :math:`y` variable.

1586

1587 Parameters

1588 ----------

1589 value

1590 Value to set the dependent and already known :math:`y` variable

1591 with.

1592

1593 Returns

1594 -------

1595 :class:`numpy.ndarray`

1596 Dependent and already known :math:`y` variable.

1597

1598 Raises

1599 ------

1600 AssertionError

1601 If the provided value has not exactly one dimension.

1602 """

1603

1604 return self._y

1605

1606 @y.setter

1607 def y(self, value: ArrayLike) -> None:

1608 """Setter for the **self.y** property."""

1609

1610 value = cast("NDArrayFloat", np.atleast_1d(value).astype(self._dtype))

1611

1612 attest(

1613 value.ndim == 1,

1614 '"y" dependent variable must have exactly one dimension!',

1615 )

1616

1617 self._y = value

1618

1619 @property

1620 def relative_tolerance(self) -> float:

1621 """

1622 Getter and setter property for the relative tolerance for numerical

1623 comparisons.

1624

1625 Parameters

1626 ----------

1627 value

1628 Value to set the relative tolerance for numerical comparisons with.

1629

1630 Returns

1631 -------

1632 :class:`float`

1633 Relative tolerance for numerical comparisons.

1634

1635 Raises

1636 ------

1637 AssertionError

1638 If the value is not numeric.

1639 """

1640

1641 return self._relative_tolerance

1642

1643 @relative_tolerance.setter

1644 def relative_tolerance(self, value: float) -> None:

1645 """Setter for the **self.relative_tolerance** property."""

1646

1647 attest(

1648 is_numeric(value),

1649 '"relative_tolerance" variable must be a "numeric"!',

1650 )

1651

1652 self._relative_tolerance = as_float_scalar(value)

1653

1654 @property

1655 def absolute_tolerance(self) -> float:

1656 """

1657 Getter and setter property for the absolute tolerance for numerical

1658 comparisons.

1659

1660 Parameters

1661 ----------

1662 value

1663 Value to set the absolute tolerance for numerical comparisons with.

1664

1665 Returns

1666 -------

1667 :class:`float`

1668 Absolute tolerance for numerical comparisons.

1669

1670 Raises

1671 ------

1672 AssertionError

1673 If the value is not numeric.

1674 """

1675

1676 return self._absolute_tolerance

1677

1678 @absolute_tolerance.setter

1679 def absolute_tolerance(self, value: float) -> None:

1680 """Setter for the **self.absolute_tolerance** property."""

1681

1682 attest(

1683 is_numeric(value),

1684 '"absolute_tolerance" variable must be a "numeric"!',

1685 )

1686

1687 self._absolute_tolerance = as_float_scalar(value)

1688

1689 @property

1690 def default(self) -> float:

1691 """

1692 Getter and setter property for the default value for call outside

1693 tolerances.

1694

1695 Parameters

1696 ----------

1697 value

1698 Value to set the default value with for call outside tolerances.

1699

1700 Returns

1701 -------

1702 :class:`float`

1703 Default value for call outside tolerances.

1704

1705 Raises

1706 ------

1707 AssertionError

1708 If the value is not numeric.

1709 """

1710

1711 return self._default

1712

1713 @default.setter

1714 def default(self, value: float) -> None:

1715 """Setter for the **self.default** property."""

1716

1717 attest(is_numeric(value), '"default" variable must be a "numeric"!')

1718

1719 self._default = value

1720

1721 def __call__(self, x: ArrayLike) -> NDArrayFloat:

1722 """

1723 Evaluate the interpolator at specified point(s).

1724

1725

1726 Parameters

1727 ----------

1728 x

1729 Point(s) to evaluate the interpolant at.

1730

1731 Returns

1732 -------

1733 :class:`numpy.ndarray`

1734 Interpolated value(s).

1735 """

1736

1737 x = as_float_array(x)

1738

1739 xi = self._evaluate(x)

1740

1741 return as_float(xi)

1742

1743 def _evaluate(self, x: NDArrayFloat) -> NDArrayFloat:

1744 """

1745 Perform the interpolator evaluation at specified points.

1746

1747 Parameters

1748 ----------

1749 x

1750 Points to evaluate the interpolant at.

1751

1752 Returns

1753 -------

1754 :class:`numpy.ndarray`

1755 Interpolated points values.

1756 """

1757

1758 self._validate_dimensions()

1759 self._validate_interpolation_range(x)

1760

1761 indexes = closest_indexes(self._x, x)

1762 values = self._y[indexes]

1763 values[

1764 ~np.isclose(

1765 self._x[indexes],

1766 x,

1767 rtol=self._absolute_tolerance,

1768 atol=self._relative_tolerance,

1769 )

1770 ] = self._default

1771

1772 return np.squeeze(values)

1773

1774 def _validate_dimensions(self) -> None:

1775 """Validate that the variables dimensions are the same."""

1776

1777 if len(self._x) != len(self._y):

1778 error = (

1779 '"x" independent and "y" dependent variables have different '

1780 f'dimensions: "{len(self._x)}", "{len(self._y)}"'

1781 )

1782

1783 raise ValueError(error)

1784

1785 def _validate_interpolation_range(self, x: NDArrayFloat) -> None:

1786 """Validate specified point to be in interpolation range."""

1787

1788 below_interpolation_range = x < self._x[0]

1789 above_interpolation_range = x > self._x[-1]

1790

1791 if below_interpolation_range.any():

1792 error = f'"{x}" is below interpolation range.'

1793

1794 raise ValueError(error)

1795

1796 if above_interpolation_range.any():

1797 error = f'"{x}" is above interpolation range.'

1798

1799 raise ValueError(error)

1800

1801

1802 def lagrange_coefficients(r: float, n: int = 4) -> NDArrayFloat:

1803 """

1804 Compute *Lagrange coefficients* at specified point :math:`r` for

1805 polynomial interpolation of degree :math:`n`.

1806

1807 Parameters

1808 ----------

1809 r

1810 Point at which to compute the *Lagrange coefficients*.

1811 n

1812 Degree of the polynomial interpolation. The number of coefficients

1813 returned will be :math:`n + 1`.

1814

1815 Returns

1816 -------

1817 :class:`numpy.ndarray`

1818 Array of *Lagrange coefficients* computed at point :math:`r`.

1819

1820 References

1821 ----------

1822 :cite:`Fairman1985b`, :cite:`Wikipedia2003a`

1823

1824 Examples

1825 --------

1826 >>> lagrange_coefficients(0.1)

1827 array([ 0.8265, 0.2755, -0.1305, 0.0285])

1828 """

1829

1830 r_i = np.arange(n)

1831 L_n = []

1832 for j in range(len(r_i)):

1833 basis = [(r - r_i[i]) / (r_i[j] - r_i[i]) for i in range(len(r_i)) if i != j]

1834 L_n.append(reduce(lambda x, y: x * y, basis))

1835

1836 return np.array(L_n)

1837

1838

1839 def table_interpolation_trilinear(V_xyz: ArrayLike, table: ArrayLike) -> NDArrayFloat:

1840 """

1841 Perform trilinear interpolation of the specified :math:`V_{xyz}` values using

1842 the specified interpolation table.

1843

1844 Parameters

1845 ----------

1846 V_xyz

1847 :math:`V_{xyz}` values to interpolate.

1848 table

1849 4-Dimensional (NxNxNx3) interpolation table.

1850

1851 Returns

1852 -------

1853 :class:`numpy.ndarray`

1854 Interpolated :math:`V_{xyz}` values.

1855

1856 References

1857 ----------

1858 :cite:`Bourkeb`

1859

1860 Examples

1861 --------

1862 >>> import os

1863 >>> import colour

1864 >>> path = os.path.join(

1865 ... os.path.dirname(__file__),

1866 ... "..",

1867 ... "io",

1868 ... "luts",

1869 ... "tests",

1870 ... "resources",

1871 ... "iridas_cube",

1872 ... "Colour_Correct.cube",

1873 ... )

1874 >>> LUT = colour.read_LUT(path)

1875 >>> table = LUT.table

1876 >>> prng = np.random.RandomState(4)

1877 >>> V_xyz = colour.algebra.random_triplet_generator(3, random_state=prng)

1878 >>> print(V_xyz) # doctest: +ELLIPSIS

1879 [[ 0.9670298... 0.7148159... 0.9762744...]

1880 [ 0.5472322... 0.6977288... 0.0062302...]

1881 [ 0.9726843... 0.2160895... 0.2529823...]]

1882 >>> table_interpolation_trilinear(V_xyz, table) # doctest: +ELLIPSIS

1883 array([[ 1.0120664..., 0.7539146..., 1.0228540...],

1884 [ 0.5075794..., 0.6479459..., 0.1066404...],

1885 [ 1.0976519..., 0.1785998..., 0.2299897...]])

1886 """

1887

1888 V_xyz = cast("NDArrayFloat", V_xyz)

1889 original_shape = V_xyz.shape

1890 V_xyz = cast("NDArrayFloat", np.clip(V_xyz, 0, 1).reshape(-1, 3))

1891

1892 # Index computation

1893 table = cast("NDArrayFloat", table)

1894 i_m = np.array(table.shape[:-1]) - 1

1895 V_xyz_s = V_xyz * i_m

1896

1897 i_f = V_xyz_s.astype(DTYPE_INT_DEFAULT)

1898 i_f = np.clip(i_f, 0, i_m)

1899 i_c = np.minimum(i_f + 1, i_m)

1900

1901 # Relative coordinates (fractional part)

1902 frac = V_xyz_s - i_f

1903

1904 # Extract indices for direct lookup

1905 fx, fy, fz = i_f[:, 0], i_f[:, 1], i_f[:, 2]

1906 cx, cy, cz = i_c[:, 0], i_c[:, 1], i_c[:, 2]

1907

1908 # Extract fractional coordinates

1909 dx, dy, dz = frac[:, 0:1], frac[:, 1:2], frac[:, 2:3]

1910 dx1, dy1, dz1 = 1.0 - dx, 1.0 - dy, 1.0 - dz

1911

1912 # Direct vertex lookups (8 corners of cube)

1913 v000 = table[fx, fy, fz]

1914 v001 = table[fx, fy, cz]

1915 v010 = table[fx, cy, fz]

1916 v011 = table[fx, cy, cz]

1917 v100 = table[cx, fy, fz]

1918 v101 = table[cx, fy, cz]

1919 v110 = table[cx, cy, fz]

1920 v111 = table[cx, cy, cz]

1921

1922 # Trilinear interpolation (vectorized)

1923 result = (

1924 v000 * (dx1 * dy1 * dz1)

1925 + v001 * (dx1 * dy1 * dz)

1926 + v010 * (dx1 * dy * dz1)

1927 + v011 * (dx1 * dy * dz)

1928 + v100 * (dx * dy1 * dz1)

1929 + v101 * (dx * dy1 * dz)

1930 + v110 * (dx * dy * dz1)

1931 + v111 * (dx * dy * dz)

1932 )

1933

1934 return result.reshape(original_shape)

1935

1936

1937 def table_interpolation_tetrahedral(V_xyz: ArrayLike, table: ArrayLike) -> NDArrayFloat:

1938 """

1939 Perform tetrahedral interpolation of the specified :math:`V_{xyz}` values

1940 using the specified 4-dimensional interpolation table.

1941

1942 Parameters

1943 ----------

1944 V_xyz

1945 :math:`V_{xyz}` values to interpolate.

1946 table

1947 4-Dimensional (NxNxNx3) interpolation table.

1948

1949 Returns

1950 -------

1951 :class:`numpy.ndarray`

1952 Interpolated :math:`V_{xyz}` values.

1953

1954 References

1955 ----------

1956 :cite:`Kirk2006`

1957

1958 Examples

1959 --------

1960 >>> import os

1961 >>> import colour

1962 >>> path = os.path.join(

1963 ... os.path.dirname(__file__),

1964 ... "..",

1965 ... "io",

1966 ... "luts",

1967 ... "tests",

1968 ... "resources",

1969 ... "iridas_cube",

1970 ... "Colour_Correct.cube",

1971 ... )

1972 >>> LUT = colour.read_LUT(path)

1973 >>> table = LUT.table

1974 >>> prng = np.random.RandomState(4)

1975 >>> V_xyz = colour.algebra.random_triplet_generator(3, random_state=prng)

1976 >>> print(V_xyz) # doctest: +ELLIPSIS

1977 [[ 0.9670298... 0.7148159... 0.9762744...]

1978 [ 0.5472322... 0.6977288... 0.0062302...]

1979 [ 0.9726843... 0.2160895... 0.2529823...]]

1980 >>> table_interpolation_tetrahedral(V_xyz, table) # doctest: +ELLIPSIS

1981 array([[ 1.0196197..., 0.7674062..., 1.0311751...],

1982 [ 0.5105603..., 0.6466722..., 0.1077296...],

1983 [ 1.1178206..., 0.1762039..., 0.2209534...]])

1984 """

1985

1986 V_xyz = cast("NDArrayFloat", V_xyz)

1987 original_shape = V_xyz.shape

1988 V_xyz = cast("NDArrayFloat", np.clip(V_xyz, 0, 1).reshape(-1, 3))

1989

1990 # Index computation

1991 table = cast("NDArrayFloat", table)

1992 i_m = np.array(table.shape[:-1]) - 1

1993 V_xyz_s = V_xyz * i_m

1994

1995 i_f = V_xyz_s.astype(DTYPE_INT_DEFAULT)

1996 i_f = np.clip(i_f, 0, i_m)

1997 i_c = np.minimum(i_f + 1, i_m)

1998

1999 # Relative coordinates

2000 r = V_xyz_s - i_f

2001 x, y, z = r[:, 0], r[:, 1], r[:, 2]

2002

2003 # Extract indices for direct lookup

2004 fx, fy, fz = i_f[:, 0], i_f[:, 1], i_f[:, 2]

2005 cx, cy, cz = i_c[:, 0], i_c[:, 1], i_c[:, 2]

2006

2007 # Look up 8 corner vertices

2008 V000 = table[fx, fy, fz]

2009 V001 = table[fx, fy, cz]

2010 V010 = table[fx, cy, fz]

2011 V011 = table[fx, cy, cz]

2012 V100 = table[cx, fy, fz]

2013 V101 = table[cx, fy, cz]

2014 V110 = table[cx, cy, fz]

2015 V111 = table[cx, cy, cz]

2016

2017 # Expand dimensions for broadcasting

2018 x = x[:, np.newaxis]

2019 y = y[:, np.newaxis]

2020 z = z[:, np.newaxis]

2021

2022 # Tetrahedral interpolation - select tetrahedron based on position

2023 xyz_o = np.select(

2024 [

2025 np.logical_and(x > y, y > z),

2026 np.logical_and(x > z, z >= y),

2027 np.logical_and(z >= x, x > y),

2028 np.logical_and(y >= x, x > z),

2029 np.logical_and(y >= z, z >= x),

2030 np.logical_and(z > y, y >= x),

2031 ],

2032 [

2033 (1 - x) * V000 + (x - y) * V100 + (y - z) * V110 + z * V111,

2034 (1 - x) * V000 + (x - z) * V100 + (z - y) * V101 + y * V111,

2035 (1 - z) * V000 + (z - x) * V001 + (x - y) * V101 + y * V111,

2036 (1 - y) * V000 + (y - x) * V010 + (x - z) * V110 + z * V111,

2037 (1 - y) * V000 + (y - z) * V010 + (z - x) * V011 + x * V111,

2038 (1 - z) * V000 + (z - y) * V001 + (y - x) * V011 + x * V111,

2039 ],

2040 )

2041

2042 return xyz_o.reshape(original_shape)

2043

2044

2045 TABLE_INTERPOLATION_METHODS = CanonicalMapping(

2046 {

2047 "Trilinear": table_interpolation_trilinear,

2048 "Tetrahedral": table_interpolation_tetrahedral,

2049 }

2050)

2051 TABLE_INTERPOLATION_METHODS.__doc__ = """

2052 Supported table interpolation methods.

2053

2054 References

2055----------

2056:cite:`Bourkeb`, :cite:`Kirk2006`

2057"""

2058

2059

2060 def table_interpolation(

2061 V_xyz: ArrayLike,

2062 table: ArrayLike,

2063 method: Literal["Trilinear", "Tetrahedral"] | str = "Trilinear",

2064) -> NDArrayFloat:

2065 """

2066 Perform interpolation of the specified :math:`V_{xyz}` values using a

2067 4-dimensional interpolation table.

2068

2069 Interpolate the input :math:`V_{xyz}` values through either trilinear

2070 or tetrahedral interpolation methods using the specified lookup table.

2071

2072 Parameters

2073 ----------

2074 V_xyz

2075 :math:`V_{xyz}` values to interpolate, where each row represents

2076 a three-dimensional coordinate within the interpolation table's

2077 domain.

2078 table

2079 4-dimensional (NxNxNx3) interpolation table defining the mapping

2080 from input coordinates to output values.

2081 method

2082 Interpolation method to use. Either "Trilinear" for trilinear

2083 interpolation or "Tetrahedral" for tetrahedral interpolation.

2084

2085 Returns

2086 -------

2087 :class:`numpy.ndarray`

2088 Interpolated :math:`V_{xyz}` values with the same shape as the

2089 input array.

2090

2091 References

2092 ----------

2093 :cite:`Bourkeb`, :cite:`Kirk2006`

2094

2095 Examples

2096 --------

2097 >>> import os

2098 >>> import colour

2099 >>> path = os.path.join(

2100 ... os.path.dirname(__file__),

2101 ... "..",

2102 ... "io",

2103 ... "luts",

2104 ... "tests",

2105 ... "resources",

2106 ... "iridas_cube",

2107 ... "Colour_Correct.cube",

2108 ... )

2109 >>> LUT = colour.read_LUT(path)

2110 >>> table = LUT.table

2111 >>> prng = np.random.RandomState(4)

2112 >>> V_xyz = colour.algebra.random_triplet_generator(3, random_state=prng)

2113 >>> print(V_xyz) # doctest: +ELLIPSIS

2114 [[ 0.9670298... 0.7148159... 0.9762744...]

2115 [ 0.5472322... 0.6977288... 0.0062302...]

2116 [ 0.9726843... 0.2160895... 0.2529823...]]

2117 >>> table_interpolation(V_xyz, table) # doctest: +ELLIPSIS

2118 array([[ 1.0120664..., 0.7539146..., 1.0228540...],

2119 [ 0.5075794..., 0.6479459..., 0.1066404...],

2120 [ 1.0976519..., 0.1785998..., 0.2299897...]])

2121 >>> table_interpolation(V_xyz, table, method="Tetrahedral")

2122 ... # doctest: +ELLIPSIS

2123 array([[ 1.0196197..., 0.7674062..., 1.0311751...],

2124 [ 0.5105603..., 0.6466722..., 0.1077296...],

2125 [ 1.1178206..., 0.1762039..., 0.2209534...]])

2126 """

2127

2128 method = validate_method(method, tuple(TABLE_INTERPOLATION_METHODS))

2129

2130 return TABLE_INTERPOLATION_METHODS[method](V_xyz, table)