Coverage for io/luts/lut.py: 65%

1"""

2 LUT Processing

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

4

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

6 operations.

7

8- :class:`colour.LUT1D`: One-dimensional lookup table for single-channel

9 transformations

10- :class:`colour.LUT3x1D`: Three parallel one-dimensional lookup tables

11 for independent RGB channel processing

12- :class:`colour.LUT3D`: Three-dimensional lookup table for complex colour

13 space transformations

14- :class:`colour.io.LUT_to_LUT`: Utility for converting between different

15 LUT formats and types

16"""

17

18 from __future__ import annotations

19

20 import typing

21 from abc import ABC, abstractmethod

22 from copy import deepcopy

23 from operator import pow # noqa: A004

24 from operator import add, iadd, imul, ipow, isub, itruediv, mul, sub, truediv

25

26 import numpy as np

27

28 from colour.algebra import (

29 Extrapolator,

30 LinearInterpolator,

31 linear_conversion,

32 table_interpolation_trilinear,

33)

34 from colour.constants import EPSILON

35

36 if typing.TYPE_CHECKING:

37 from colour.hints import (

38 Any,

39 ArrayLike,

40 Literal,

41 NDArrayFloat,

42 Self,

43 Sequence,

44 Type,

45 )

46

47 from colour.hints import List, cast

48 from colour.utilities import (

49 as_array,

50 as_float_array,

51 as_int,

52 as_int_array,

53 as_int_scalar,

54 attest,

55 full,

56 is_iterable,

57 is_numeric,

58 multiline_repr,

59 multiline_str,

60 optional,

61 required,

62 runtime_warning,

63 tsplit,

64 tstack,

65 usage_warning,

66 validate_method,

67)

68

69 __author__ = "Colour Developers"

70 __copyright__ = "Copyright 2013 Colour Developers"

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

72 __maintainer__ = "Colour Developers"

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

74 __status__ = "Production"

75

76 __all__ = [

77 "AbstractLUT",

78 "LUT1D",

79 "LUT3x1D",

80 "LUT3D",

81 "LUT_to_LUT",

82]

83

84

85 class AbstractLUT(ABC):

86 """

87 Define the base class for *LUT* (Look-Up Table).

88

89 This is an abstract base class (:class:`ABCMeta`) that must be inherited

90 by concrete *LUT* implementations to provide common functionality and

91 interface specifications.

92

93 Parameters

94 ----------

95 table

96 Underlying *LUT* table array containing the lookup values.

97 name

98 *LUT* identifying name.

99 dimensions

100 *LUT* dimensionality: typically 1 for a 1D *LUT*, 2 for a 3x1D *LUT*,

101 and 3 for a 3D *LUT*.

102 domain

103 *LUT* input domain boundaries, also used to define the instantiation

104 time default table domain.

105 size

106 *LUT* resolution or sampling density, also used to define the

107 instantiation time default table size.

108 comments

109 Additional comments or metadata to associate with the *LUT*.

110

111 Attributes

112 ----------

113 - :attr:`~colour.io.luts.lut.AbstractLUT.table`

114 - :attr:`~colour.io.luts.lut.AbstractLUT.name`

115 - :attr:`~colour.io.luts.lut.AbstractLUT.dimensions`

116 - :attr:`~colour.io.luts.lut.AbstractLUT.domain`

117 - :attr:`~colour.io.luts.lut.AbstractLUT.size`

118 - :attr:`~colour.io.luts.lut.AbstractLUT.comments`

119

120 Methods

121 -------

122 - :meth:`~colour.io.luts.lut.AbstractLUT.__init__`

123 - :meth:`~colour.io.luts.lut.AbstractLUT.__str__`

124 - :meth:`~colour.io.luts.lut.AbstractLUT.__repr__`

125 - :meth:`~colour.io.luts.lut.AbstractLUT.__eq__`

126 - :meth:`~colour.io.luts.lut.AbstractLUT.__ne__`

127 - :meth:`~colour.io.luts.lut.AbstractLUT.__add__`

128 - :meth:`~colour.io.luts.lut.AbstractLUT.__iadd__`

129 - :meth:`~colour.io.luts.lut.AbstractLUT.__sub__`

130 - :meth:`~colour.io.luts.lut.AbstractLUT.__isub__`

131 - :meth:`~colour.io.luts.lut.AbstractLUT.__mul__`

132 - :meth:`~colour.io.luts.lut.AbstractLUT.__imul__`

133 - :meth:`~colour.io.luts.lut.AbstractLUT.__div__`

134 - :meth:`~colour.io.luts.lut.AbstractLUT.__idiv__`

135 - :meth:`~colour.io.luts.lut.AbstractLUT.__pow__`

136 - :meth:`~colour.io.luts.lut.AbstractLUT.__ipow__`

137 - :meth:`~colour.io.luts.lut.AbstractLUT.arithmetical_operation`

138 - :meth:`~colour.io.luts.lut.AbstractLUT.is_domain_explicit`

139 - :meth:`~colour.io.luts.lut.AbstractLUT.linear_table`

140 - :meth:`~colour.io.luts.lut.AbstractLUT.copy`

141 - :meth:`~colour.io.luts.lut.AbstractLUT.invert`

142 - :meth:`~colour.io.luts.lut.AbstractLUT.apply`

143 - :meth:`~colour.io.luts.lut.AbstractLUT.convert`

144 """

145

146 def __init__(

147 self,

148 table: ArrayLike | None = None,

149 name: str | None = None,

150 dimensions: int | None = None,

151 domain: ArrayLike | None = None,

152 size: ArrayLike | None = None,

153 comments: Sequence | None = None,

154 ) -> None:

155 self._name: str = f"Unity {size! r}" if table is None else f"{id(self)}"

156 self.name = optional(name, self._name)

157 self._dimensions = optional(dimensions, 0)

158 self._table: NDArrayFloat = self.linear_table(

159 optional(size, 0), optional(domain, np.array([]))

160 )

161 self.table = optional(table, self._table)

162 self._domain: NDArrayFloat = np.array([])

163 self.domain = optional(domain, self._domain)

164 self._comments: list = []

165 self.comments = cast("list", optional(comments, self._comments))

166

167 @property

168 def table(self) -> NDArrayFloat:

169 """

170 Getter and setter for the underlying *LUT* table.

171

172 Access or modify the lookup table data structure that defines the

173 transformation mapping for this LUT instance.

174

175 Parameters

176 ----------

177 value

178 Value to set the underlying *LUT* table with.

179

180 Returns

181 -------

182 :class:`numpy.ndarray`

183 Underlying *LUT* table.

184 """

185

186 return self._table

187

188 @table.setter

189 def table(self, value: ArrayLike) -> None:

190 """Setter for the **self.table** property."""

191

192 self._table = self._validate_table(value)

193

194 @property

195 def name(self) -> str:

196 """

197 Getter and setter for the *LUT* name.

198

199 Parameters

200 ----------

201 value

202 Value to set the *LUT* name with.

203

204 Returns

205 -------

206 :class:`str`

207 *LUT* name.

208 """

209

210 return self._name

211

212 @name.setter

213 def name(self, value: str) -> None:

214 """Setter for the **self.name** property."""

215

216 attest(

217 isinstance(value, str),

218 f'"name" property: "{value}" type is not "str"!',

219 )

220

221 self._name = value

222

223 @property

224 def domain(self) -> NDArrayFloat:

225 """

226 Getter and setter for the *LUT* domain.

227

228 The domain defines the input coordinate space for the lookup table,

229 specifying the valid range of input values that can be interpolated.

230

231 Parameters

232 ----------

233 value

234 Value to set the *LUT* domain with.

235

236 Returns

237 -------

238 :class:`numpy.ndarray`

239 *LUT* domain.

240 """

241

242 return self._domain

243

244 @domain.setter

245 def domain(self, value: ArrayLike) -> None:

246 """Setter for the **self.domain** property."""

247

248 self._domain = self._validate_domain(value)

249

250 @property

251 def dimensions(self) -> int:

252 """

253 Getter for the *LUT* dimensions.

254

255 Returns

256 -------

257 :class:`int`

258 *LUT* dimensions.

259 """

260

261 return self._dimensions

262

263 @property

264 def size(self) -> int:

265 """

266 Getter for the *LUT* size.

267

268 Returns

269 -------

270 :class:`int`

271 *LUT* size.

272 """

273

274 return self._table.shape[0]

275

276 @property

277 def comments(self) -> list:

278 """

279 Getter and setter for the *LUT* comments.

280

281 Parameters

282 ----------

283 value

284 Value to set the *LUT* comments with.

285

286 Returns

287 -------

288 :class:`list`

289 *LUT* comments.

290 """

291

292 return self._comments

293

294 @comments.setter

295 def comments(self, value: Sequence) -> None:

296 """Setter for the **self.comments** property."""

297

298 attest(

299 is_iterable(value),

300 f'"comments" property: "{value}" must be a sequence!',

301 )

302

303 self._comments = list(value)

304

305 def __str__(self) -> str:

306 """

307 Return a formatted string representation of the *LUT*.

308

309 Returns

310 -------

311 :class:`str`

312 Formatted string representation.

313 """

314

315 attributes = [

316 {

317 "formatter": lambda x: ( # noqa: ARG005

318 f"{self.__class__.__name__} - {self.name}"

319 ),

320 "section": True,

321 },

322 {"line_break": True},

323 {"name": "dimensions", "label": "Dimensions"},

324 {"name": "domain", "label": "Domain"},

325 {

326 "label": "Size",

327 "formatter": lambda x: str(self.table.shape), # noqa: ARG005

328 },

329 ]

330

331 if self.comments:

332 attributes.append(

333 {

334 "formatter": lambda x: "\n".join( # noqa: ARG005

335 [

336 f"Comment {str(i + 1).zfill(2)} : {comment}"

337 for i, comment in enumerate(self.comments)

338 ]

339 ),

340 }

341 )

342

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

344

345 def __repr__(self) -> str:

346 """

347 Return an evaluable string representation of the *LUT*.

348

349 This method provides a string that, when evaluated, recreates the

350 *LUT* object with its current state and configuration.

351

352 Returns

353 -------

354 :class:`str`

355 Evaluable string representation.

356 """

357

358 attributes = [

359 {"name": "table"},

360 {"name": "name"},

361 {"name": "domain"},

362 {"name": "size"},

363 ]

364

365 if self.comments:

366 attributes.append({"name": "comments"})

367

368 return multiline_repr(self, attributes)

369

370 __hash__ = None # pyright: ignore

371

372 def __eq__(self, other: object) -> bool:

373 """

374 Return whether the *LUT* is equal to the specified other object.

375

376 Parameters

377 ----------

378 other

379 Object to test whether it is equal to the *LUT*.

380

381 Returns

382 -------

383 :class:`bool`

384 Whether the specified object is equal to the *LUT*.

385 """

386

387 return isinstance(other, AbstractLUT) and all(

388 [

389 np.array_equal(self.table, other.table),

390 np.array_equal(self.domain, other.domain),

391 ]

392 )

393

394 def __ne__(self, other: object) -> bool:

395 """

396 Determine whether the *LUT* is not equal to the specified other

397 object.

398

399 Parameters

400 ----------

401 other

402 Object to test for inequality with the *LUT*.

403

404 Returns

405 -------

406 :class:`bool`

407 Whether the specified object is not equal to the *LUT*.

408 """

409

410 return not (self == other)

411

412 def __add__(self, a: ArrayLike | AbstractLUT) -> AbstractLUT:

413 """

414 Implement support for addition.

415

416 Parameters

417 ----------

418 a

419 *a* variable to add.

420

421 Returns

422 -------

423 :class:`colour.io.luts.lut.AbstractLUT`

424 Variable added *LUT*.

425 """

426

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

428

429 def __iadd__(self, a: ArrayLike | AbstractLUT) -> Self:

430 """

431 Implement support for in-place addition.

432

433 Add the specified operand to this *LUT* in-place, modifying the

434 current instance rather than creating a new one.

435

436 Parameters

437 ----------

438 a

439 Operand to add in-place. Can be a numeric array or another

440 *LUT* instance with compatible dimensions.

441

442 Returns

443 -------

444 :class:`colour.io.luts.lut.AbstractLUT`

445 Current *LUT* instance with the addition applied in-place.

446 """

447

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

449

450 def __sub__(self, a: ArrayLike | AbstractLUT) -> Self:

451 """

452 Implement support for subtraction.

453

454 Parameters

455 ----------

456 a

457 Variable, array or *LUT* to subtract from the current *LUT*.

458

459 Returns

460 -------

461 :class:`colour.io.luts.lut.AbstractLUT`

462 Variable subtracted *LUT*.

463 """

464

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

466

467 def __isub__(self, a: ArrayLike | AbstractLUT) -> Self:

468 """

469 Implement support for in-place subtraction.

470

471 Parameters

472 ----------

473 a

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

475

476 Returns

477 -------

478 :class:`colour.io.luts.lut.AbstractLUT`

479 In-place variable subtracted *LUT*.

480 """

481

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

483

484 def __mul__(self, a: ArrayLike | AbstractLUT) -> Self:

485 """

486 Implement support for multiplication.

487

488 Parameters

489 ----------

490 a

491 Variable to multiply with the *LUT*. Can be a numeric array or

492 another *LUT* instance.

493

494 Returns

495 -------

496 :class:`colour.io.luts.lut.AbstractLUT`

497 Variable multiplied *LUT*.

498 """

499

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

501

502 def __imul__(self, a: ArrayLike | AbstractLUT) -> Self:

503 """

504 Implement support for in-place multiplication.

505

506 Parameters

507 ----------

508 a

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

510

511 Returns

512 -------

513 :class:`colour.io.luts.lut.AbstractLUT`

514 In-place variable multiplied *LUT*.

515 """

516

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

518

519 def __div__(self, a: ArrayLike | AbstractLUT) -> Self:

520 """

521 Implement support for division.

522

523 Parameters

524 ----------

525 a

526 :math:`a` variable to divide by.

527

528 Returns

529 -------

530 :class:`colour.io.luts.lut.AbstractLUT`

531 Variable divided *LUT*.

532 """

533

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

535

536 def __idiv__(self, a: ArrayLike | AbstractLUT) -> Self:

537 """

538 Perform in-place division of the *LUT* by the specified operand.

539

540 Parameters

541 ----------

542 a

543 Operand to divide the *LUT* by in-place.

544

545 Returns

546 -------

547 :class:`colour.io.luts.lut.AbstractLUT`

548 Current *LUT* instance with the division applied in-place.

549 """

550

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

552

553 __itruediv__ = __idiv__

554 __truediv__ = __div__

555

556 def __pow__(self, a: ArrayLike | AbstractLUT) -> Self:

557 """

558 Implement support for exponentiation.

559

560 Parameters

561 ----------

562 a

563 :math:`a` variable to exponentiate by.

564

565 Returns

566 -------

567 :class:`colour.io.luts.lut.AbstractLUT`

568 Variable exponentiated *LUT*.

569 """

570

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

572

573 def __ipow__(self, a: ArrayLike | AbstractLUT) -> Self:

574 """

575 Implement support for in-place exponentiation.

576

577 Parameters

578 ----------

579 a

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

581

582 Returns

583 -------

584 :class:`colour.io.luts.lut.AbstractLUT`

585 In-place variable exponentiated *LUT*.

586 """

587

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

589

590 def arithmetical_operation(

591 self,

592 a: ArrayLike | AbstractLUT,

593 operation: Literal["+", "-", "*", "/", "**"],

594 in_place: bool = False,

595 ) -> Self:

596 """

597 Perform the specified arithmetical operation with the :math:`a`

598 operand.

599

600 Execute the requested mathematical operation between this *LUT*

601 instance and the specified operand. The operation can be performed

602 either on a copy of the *LUT* or in-place on the current instance.

603 This method must be reimplemented by sub-classes to handle their

604 specific table structures.

605

606 Parameters

607 ----------

608 a

609 Operand for the arithmetical operation. Can be either a numeric

610 array or another *LUT* instance with compatible dimensions.

611 operation

612 Arithmetical operation to perform. Supported operations are

613 addition (``+``), subtraction (``-``), multiplication (``*``),

614 division (``/``), and exponentiation (``**``).

615 in_place

616 Whether to perform the operation in-place on the current *LUT*

617 instance (``True``) or on a copy (``False``).

618

619 Returns

620 -------

621 :class:`colour.io.luts.lut.AbstractLUT`

622 Modified *LUT* instance. If ``in_place`` is ``True``, returns

623 the current instance after modification. If ``False``, returns

624 a new modified copy.

625 """

626

627 operator, ioperator = {

628 "+": (add, iadd),

629 "-": (sub, isub),

630 "*": (mul, imul),

631 "/": (truediv, itruediv),

632 "**": (pow, ipow),

633 }[operation]

634

635 if in_place:

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

637

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

639

640 return self

641

642 return ioperator(self.copy(), a)

643

644 @abstractmethod

645 def _validate_table(self, table: ArrayLike) -> NDArrayFloat:

646 """

647 Validate the specified table according to *LUT* dimensions.

648

649 Parameters

650 ----------

651 table

652 Table to validate.

653

654 Returns

655 -------

656 :class:`numpy.ndarray`

657 Validated table as a :class:`ndarray` instance.

658 """

659

660 @abstractmethod

661 def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat:

662 """

663 Validate specified domain according to *LUT* dimensions.

664

665 Parameters

666 ----------

667 domain

668 Domain to validate.

669

670 Returns

671 -------

672 :class:`numpy.ndarray`

673 Validated domain as a :class:`ndarray` instance.

674 """

675

676 @abstractmethod

677 def is_domain_explicit(self) -> bool:

678 """

679 Return whether the *LUT* domain is explicit (or implicit).

680

681 An implicit domain is defined by its shape only::

682

683 [[0 1]

684 [0 1]

685 [0 1]]

686

687 While an explicit domain defines every single discrete sample::

688

689 [[0.0 0.0 0.0]

690 [0.1 0.1 0.1]

691 [0.2 0.2 0.2]

692 [0.3 0.3 0.3]

693 [0.4 0.4 0.4]

694 [0.8 0.8 0.8]

695 [1.0 1.0 1.0]]

696

697 Returns

698 -------

699 :class:`bool`

700 Is *LUT* domain explicit.

701 """

702

703 @staticmethod

704 @abstractmethod

705 def linear_table(

706 size: ArrayLike | None = None,

707 domain: ArrayLike | None = None,

708 ) -> NDArrayFloat:

709 """

710 Generate a linear table of the specified size according to LUT dimensions.

711

712 Parameters

713 ----------

714 size

715 Expected table size, for a 1D *LUT*, the number of output samples

716 :math:`n` is equal to ``size``, for a 3x1D *LUT* :math:`n` is equal

717 to ``size * 3`` or ``size[0] + size[1] + size[2]``, for a 3D *LUT*

718 :math:`n` is equal to ``size**3 * 3`` or

719 ``size[0] * size[1] * size[2] * 3``.

720 domain

721 Domain of the table.

722

723 Returns

724 -------

725 :class:`numpy.ndarray`

726 Linear table.

727 """

728

729 def copy(self) -> AbstractLUT:

730 """

731 Return a copy of the sub-class instance.

732

733 Returns

734 -------

735 :class:`colour.io.luts.lut.AbstractLUT`

736 Copy of the LUT instance.

737 """

738

739 return deepcopy(self)

740

741 @abstractmethod

742 def invert(self, **kwargs: Any) -> AbstractLUT:

743 """

744 Compute and return an inverse copy of the *LUT*.

745

746 Other Parameters

747 ----------------

748 kwargs

749 Keywords arguments.

750

751 Returns

752 -------

753 :class:`colour.io.luts.lut.AbstractLUT`

754 Inverse *LUT* class instance.

755 """

756

757 @abstractmethod

758 def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat:

759 """

760 Apply the *LUT* to the specified *RGB* colourspace array using the

761 specified method.

762

763 Parameters

764 ----------

765 RGB

766 *RGB* colourspace array to apply the *LUT* onto.

767

768 Other Parameters

769 ----------------

770 direction

771 Whether the *LUT* should be applied in the forward or inverse

772 direction.

773 extrapolator

774 Extrapolator class type or object to use as extrapolating

775 function.

776 extrapolator_kwargs

777 Arguments to use when instantiating or calling the extrapolating

778 function.

779 interpolator

780 Interpolator class type or object to use as interpolating

781 function.

782 interpolator_kwargs

783 Arguments to use when instantiating or calling the interpolating

784 function.

785

786 Returns

787 -------

788 :class:`numpy.ndarray`

789 Interpolated *RGB* colourspace array.

790 """

791

792 def convert(

793 self,

794 cls: Type[AbstractLUT],

795 force_conversion: bool = False,

796 **kwargs: Any,

797 ) -> AbstractLUT:

798 """

799 Convert the *LUT* to the specified ``cls`` class instance.

800

801 Parameters

802 ----------

803 cls

804 *LUT* class instance.

805 force_conversion

806 Whether to force the conversion as it might be destructive.

807

808 Other Parameters

809 ----------------

810 interpolator

811 Interpolator class type to use as interpolating function.

812 interpolator_kwargs

813 Arguments to use when instantiating the interpolating function.

814 size

815 Expected table size in case of an upcast to or a downcast from a

816 :class:`LUT3D` class instance.

817

818 Returns

819 -------

820 :class:`colour.io.luts.lut.AbstractLUT`

821 Converted *LUT* class instance.

822

823 Warnings

824 --------

825 Some conversions are destructive and raise a :class:`ValueError`

826 exception by default.

827

828 Raises

829 ------

830 ValueError

831 If the conversion is destructive.

832 """

833

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

835

836

837 class LUT1D(AbstractLUT):

838 """

839 Define the base class for a 1D *LUT*.

840

841 A 1D (one-dimensional) lookup table provides a mapping function from

842 input values to output values through interpolation of discrete table

843 entries. This class is commonly used for tone mapping, gamma correction,

844 and other single-channel transformations where the output depends solely

845 on the input value.

846

847 Parameters

848 ----------

849 table

850 Underlying *LUT* table.

851 name

852 *LUT* name.

853 domain

854 *LUT* domain, also used to define the instantiation time default table

855 domain.

856 size

857 Size of the instantiation time default table, default to 10.

858 comments

859 Comments to add to the *LUT*.

860

861 Methods

862 -------

863 - :meth:`~colour.LUT1D.__init__`

864 - :meth:`~colour.LUT1D.is_domain_explicit`

865 - :meth:`~colour.LUT1D.linear_table`

866 - :meth:`~colour.LUT1D.invert`

867 - :meth:`~colour.LUT1D.apply`

868

869 Examples

870 --------

871 Instantiating a unity LUT with a table with 16 elements:

872

873 >>> print(LUT1D(size=16))

874 LUT1D - Unity 16

875 ----------------

876 <BLANKLINE>

877 Dimensions : 1

878 Domain : [ 0. 1.]

879 Size : (16,)

880

881 Instantiating a LUT using a custom table with 16 elements:

882

883 >>> print(LUT1D(LUT1D.linear_table(16) ** (1 / 2.2))) # doctest: +ELLIPSIS

884 LUT1D - ...

885 --------...

886 <BLANKLINE>

887 Dimensions : 1

888 Domain : [ 0. 1.]

889 Size : (16,)

890

891 Instantiating a LUT using a custom table with 16 elements, custom name,

892 custom domain and comments:

893

894 >>> from colour.algebra import spow

895 >>> domain = np.array([-0.1, 1.5])

896 >>> print(

897 ... LUT1D(

898 ... spow(LUT1D.linear_table(16, domain), 1 / 2.2),

899 ... "My LUT",

900 ... domain,

901 ... comments=["A first comment.", "A second comment."],

902 ... )

903 ... )

904 LUT1D - My LUT

905 --------------

906 <BLANKLINE>

907 Dimensions : 1

908 Domain : [-0.1 1.5]

909 Size : (16,)

910 Comment 01 : A first comment.

911 Comment 02 : A second comment.

912 """

913

914 def __init__(

915 self,

916 table: ArrayLike | None = None,

917 name: str | None = None,

918 domain: ArrayLike | None = None,

919 size: ArrayLike | None = None,

920 comments: Sequence | None = None,

921 ) -> None:

922 domain = as_float_array(optional(domain, np.array([0, 1])))

923 size = optional(size, 10)

924

925 super().__init__(table, name, 1, domain, size, comments)

926

927 def _validate_table(self, table: ArrayLike) -> NDArrayFloat:

928 """

929 Validate that the specified table is a 1D array.

930

931 Parameters

932 ----------

933 table

934 Table to validate.

935

936 Returns

937 -------

938 :class:`numpy.ndarray`

939 Validated table as a :class:`numpy.ndarray` instance.

940 """

941

942 table = as_float_array(table)

943

944 attest(len(table.shape) == 1, "The table must be a 1D array!")

945

946 return table

947

948 def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat:

949 """

950 Validate specified domain.

951

952 Parameters

953 ----------

954 domain

955 Domain to validate.

956

957 Returns

958 -------

959 :class:`numpy.ndarray`

960 Validated domain as a :class:`ndarray` instance.

961 """

962

963 domain = as_float_array(domain)

964

965 attest(len(domain.shape) == 1, "The domain must be a 1D array!")

966

967 attest(

968 domain.shape[0] >= 2,

969 "The domain column count must be equal or greater than 2!",

970 )

971

972 return domain

973

974 def is_domain_explicit(self) -> bool:

975 """

976 Return whether the *LUT* domain is explicit (or implicit).

977

978 An implicit domain is defined by its shape only::

979

980 [0 1]

981

982 While an explicit domain defines every single discrete samples::

983

984 [0.0 0.1 0.2 0.4 0.8 1.0]

985

986 Returns

987 -------

988 :class:`bool`

989 Is *LUT* domain explicit.

990

991 Examples

992 --------

993 >>> LUT1D().is_domain_explicit()

994 False

995 >>> table = domain = np.linspace(0, 1, 10)

996 >>> LUT1D(table, domain=domain).is_domain_explicit()

997 True

998 """

999

1000 return len(self.domain) != 2

1001

1002 @staticmethod

1003 def linear_table(

1004 size: ArrayLike | None = None,

1005 domain: ArrayLike | None = None,

1006 ) -> NDArrayFloat:

1007 """

1008 Generate a linear table with the specified number of output samples

1009 :math:`n`.

1010

1011 The table contains linearly spaced values across the specified domain.

1012 If no domain is provided, the default domain [0, 1] is used.

1013

1014 Parameters

1015 ----------

1016 size

1017 Number of samples in the output table. Default is 10.

1018 domain

1019 Domain boundaries of the table as a 2-element array [min, max]

1020 or an array of values whose minimum and maximum define the

1021 domain. Default is [0, 1].

1022

1023 Returns

1024 -------

1025 :class:`numpy.ndarray`

1026 Linear table containing ``size`` evenly spaced samples across

1027 the specified domain.

1028

1029 Examples

1030 --------

1031 >>> LUT1D.linear_table(5, np.array([-0.1, 1.5]))

1032 array([-0.1, 0.3, 0.7, 1.1, 1.5])

1033 >>> LUT1D.linear_table(domain=np.linspace(-0.1, 1.5, 5))

1034 array([-0.1, 0.3, 0.7, 1.1, 1.5])

1035 """

1036

1037 size = optional(size, 10)

1038 domain = as_float_array(optional(domain, np.array([0, 1])))

1039

1040 if len(domain) != 2:

1041 return domain

1042

1043 attest(is_numeric(size), "Linear table size must be a numeric!")

1044

1045 return np.linspace(domain[0], domain[1], as_int_scalar(size))

1046

1047 def invert(self, **kwargs: Any) -> LUT1D: # noqa: ARG002

1048 """

1049 Compute and return an inverse copy of the *LUT*.

1050

1051 Other Parameters

1052 ----------------

1053 kwargs

1054 Keywords arguments, only specified for signature compatibility

1055 with the :meth:`AbstractLUT.invert` method.

1056

1057 Returns

1058 -------

1059 :class:`colour.LUT1D`

1060 Inverse *LUT* class instance.

1061

1062 Examples

1063 --------

1064 >>> LUT = LUT1D(LUT1D.linear_table() ** (1 / 2.2))

1065 >>> print(LUT.table) # doctest: +ELLIPSIS

1066 [ 0. ... 0.3683438... 0.5047603... 0.6069133... \

1067 0.6916988... 0.7655385...

1068 0.8316843... 0.8920493... 0.9478701... 1. ]

1069 >>> print(LUT.invert()) # doctest: +ELLIPSIS

1070 LUT1D - ... - Inverse

1071 --------...----------

1072 <BLANKLINE>

1073 Dimensions : 1

1074 Domain : [ 0. 0.3683438... 0.5047603... 0.6069133... \

1075 0.6916988... 0.7655385...

1076 0.8316843... 0.8920493... 0.9478701... 1. ]

1077 Size : (10,)

1078 >>> print(LUT.invert().table) # doctest: +ELLIPSIS

1079 [ 0. ... 0.1111111... 0.2222222... 0.3333333... \

1080 0.4444444... 0.5555555...

1081 0.6666666... 0.7777777... 0.8888888... 1. ]

1082 """

1083

1084 if self.is_domain_explicit():

1085 domain = self.domain

1086 else:

1087 domain_min, domain_max = self.domain

1088 domain = np.linspace(domain_min, domain_max, self.size)

1089

1090 return LUT1D(

1091 table=domain,

1092 name=f"{self.name} - Inverse",

1093 domain=self.table,

1094 )

1095

1096 def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat:

1097 """

1098 Apply the *LUT* to the specified *RGB* colourspace array using the

1099 specified method.

1100

1101 Parameters

1102 ----------

1103 RGB

1104 *RGB* colourspace array to apply the *LUT* onto.

1105

1106 Other Parameters

1107 ----------------

1108 direction

1109 Whether the *LUT* should be applied in the forward or inverse

1110 direction.

1111 extrapolator

1112 Extrapolator class type or object to use as extrapolating

1113 function.

1114 extrapolator_kwargs

1115 Arguments to use when instantiating or calling the extrapolating

1116 function.

1117 interpolator

1118 Interpolator class type to use as interpolating function.

1119 interpolator_kwargs

1120 Arguments to use when instantiating the interpolating function.

1121

1122 Returns

1123 -------

1124 :class:`numpy.ndarray`

1125 Interpolated *RGB* colourspace array.

1126

1127 Examples

1128 --------

1129 >>> LUT = LUT1D(LUT1D.linear_table() ** (1 / 2.2))

1130 >>> RGB = np.array([0.18, 0.18, 0.18])

1131

1132 *LUT* applied to the specified *RGB* colourspace in the forward

1133 direction:

1134

1135 >>> LUT.apply(RGB) # doctest: +ELLIPSIS

1136 array([ 0.4529220..., 0.4529220..., 0.4529220...])

1137

1138 *LUT* applied to the modified *RGB* colourspace in the inverse

1139 direction:

1140

1141 >>> LUT.apply(LUT.apply(RGB), direction="Inverse")

1142 ... # doctest: +ELLIPSIS

1143 array([ 0.18..., 0.18..., 0.18...])

1144 """

1145

1146 direction = validate_method(

1147 kwargs.get("direction", "Forward"), ("Forward", "Inverse")

1148 )

1149

1150 interpolator = kwargs.get("interpolator", LinearInterpolator)

1151 interpolator_kwargs = kwargs.get("interpolator_kwargs", {})

1152 extrapolator = kwargs.get("extrapolator", Extrapolator)

1153 extrapolator_kwargs = kwargs.get("extrapolator_kwargs", {})

1154

1155 LUT = self.invert() if direction == "inverse" else self

1156

1157 if LUT.is_domain_explicit():

1158 samples = LUT.domain

1159 else:

1160 domain_min, domain_max = LUT.domain

1161 samples = np.linspace(domain_min, domain_max, LUT.size)

1162

1163 RGB_interpolator = extrapolator(

1164 interpolator(samples, LUT.table, **interpolator_kwargs),

1165 **extrapolator_kwargs,

1166 )

1167

1168 return RGB_interpolator(RGB)

1169

1170

1171 class LUT3x1D(AbstractLUT):

1172 """

1173 Define the base class for a 3x1D *LUT*.

1174

1175 A 3x1D (three-by-one-dimensional) lookup table applies independent

1176 transformations to each channel of a three-channel input. Each channel

1177 has its own 1D lookup table, enabling per-channel colour corrections

1178 and tone mapping operations.

1179

1180 Parameters

1181 ----------

1182 table

1183 Underlying *LUT* table.

1184 name

1185 *LUT* name.

1186 domain

1187 *LUT* domain, also used to define the instantiation time default

1188 table domain.

1189 size

1190 Size of the instantiation time default table, default to 10.

1191 comments

1192 Comments to add to the *LUT*.

1193

1194 Methods

1195 -------

1196 - :meth:`~colour.LUT3x1D.__init__`

1197 - :meth:`~colour.LUT3x1D.is_domain_explicit`

1198 - :meth:`~colour.LUT3x1D.linear_table`

1199 - :meth:`~colour.LUT3x1D.invert`

1200 - :meth:`~colour.LUT3x1D.apply`

1201

1202 Examples

1203 --------

1204 Instantiating a unity LUT with a table with 16x3 elements:

1205

1206 >>> print(LUT3x1D(size=16))

1207 LUT3x1D - Unity 16

1208 ------------------

1209 <BLANKLINE>

1210 Dimensions : 2

1211 Domain : [[ 0. 0. 0.]

1212 [ 1. 1. 1.]]

1213 Size : (16, 3)

1214

1215 Instantiating a LUT using a custom table with 16x3 elements:

1216

1217 >>> print(LUT3x1D(LUT3x1D.linear_table(16) ** (1 / 2.2)))

1218 ... # doctest: +ELLIPSIS

1219 LUT3x1D - ...

1220 ----------...

1221 <BLANKLINE>

1222 Dimensions : 2

1223 Domain : [[ 0. 0. 0.]

1224 [ 1. 1. 1.]]

1225 Size : (16, 3)

1226

1227 Instantiating a LUT using a custom table with 16x3 elements, custom

1228 name, custom domain and comments:

1229

1230 >>> from colour.algebra import spow

1231 >>> domain = np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]])

1232 >>> print(

1233 ... LUT3x1D(

1234 ... spow(LUT3x1D.linear_table(16), 1 / 2.2),

1235 ... "My LUT",

1236 ... domain,

1237 ... comments=["A first comment.", "A second comment."],

1238 ... )

1239 ... )

1240 LUT3x1D - My LUT

1241 ----------------

1242 <BLANKLINE>

1243 Dimensions : 2

1244 Domain : [[-0.1 -0.2 -0.4]

1245 [ 1.5 3. 6. ]]

1246 Size : (16, 3)

1247 Comment 01 : A first comment.

1248 Comment 02 : A second comment.

1249 """

1250

1251 def __init__(

1252 self,

1253 table: ArrayLike | None = None,

1254 name: str | None = None,

1255 domain: ArrayLike | None = None,

1256 size: ArrayLike | None = None,

1257 comments: Sequence | None = None,

1258 ) -> None:

1259 domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]]))

1260 size = optional(size, 10)

1261

1262 super().__init__(table, name, 2, domain, size, comments)

1263

1264 def _validate_table(self, table: ArrayLike) -> NDArrayFloat:

1265 """

1266 Validate specified table is a 3x1D array.

1267

1268 Parameters

1269 ----------

1270 table

1271 Table to validate.

1272

1273 Returns

1274 -------

1275 :class:`numpy.ndarray`

1276 Validated table as a :class:`ndarray` instance.

1277 """

1278

1279 table = as_float_array(table)

1280

1281 attest(len(table.shape) == 2, "The table must be a 2D array!")

1282

1283 return table

1284

1285 def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat:

1286 """

1287 Validate the specified domain for the lookup table.

1288

1289 Parameters

1290 ----------

1291 domain

1292 Domain to validate.

1293

1294 Returns

1295 -------

1296 :class:`numpy.ndarray`

1297 Validated domain as a :class:`ndarray` instance.

1298 """

1299

1300 domain = as_float_array(domain)

1301

1302 attest(len(domain.shape) == 2, "The domain must be a 2D array!")

1303

1304 attest(

1305 domain.shape[0] >= 2,

1306 "The domain row count must be equal or greater than 2!",

1307 )

1308

1309 attest(domain.shape[1] == 3, "The domain column count must be equal to 3!")

1310

1311 return domain

1312

1313 def is_domain_explicit(self) -> bool:

1314 """

1315 Return whether the *LUT* domain is explicit (or implicit).

1316

1317 An implicit domain is defined by its shape only::

1318

1319 [[0 1]

1320 [0 1]

1321 [0 1]]

1322

1323 While an explicit domain defines every single discrete samples::

1324

1325 [[0.0 0.0 0.0]

1326 [0.1 0.1 0.1]

1327 [0.2 0.2 0.2]

1328 [0.3 0.3 0.3]

1329 [0.4 0.4 0.4]

1330 [0.8 0.8 0.8]

1331 [1.0 1.0 1.0]]

1332

1333 Returns

1334 -------

1335 :class:`bool`

1336 Is *LUT* domain explicit.

1337

1338 Examples

1339 --------

1340 >>> LUT3x1D().is_domain_explicit()

1341 False

1342 >>> samples = np.linspace(0, 1, 10)

1343 >>> table = domain = tstack([samples, samples, samples])

1344 >>> LUT3x1D(table, domain=domain).is_domain_explicit()

1345 True

1346 """

1347

1348 return self.domain.shape != (2, 3)

1349

1350 @staticmethod

1351 def linear_table(

1352 size: ArrayLike | None = None,

1353 domain: ArrayLike | None = None,

1354 ) -> NDArrayFloat:

1355 """

1356 Generate a linear table with the specified size and domain.

1357

1358 The number of output samples :math:`n` is equal to ``size * 3`` or

1359 ``size[0] + size[1] + size[2]``.

1360

1361 Parameters

1362 ----------

1363 size

1364 Expected table size, default to 10.

1365 domain

1366 Domain of the table.

1367

1368 Returns

1369 -------

1370 :class:`numpy.ndarray`

1371 Linear table with ``size * 3`` or ``size[0] + size[1] +

1372 size[2]`` samples.

1373

1374 Warnings

1375 --------

1376 If ``size`` is non uniform, the linear table will be padded

1377 accordingly.

1378

1379 Examples

1380 --------

1381 >>> LUT3x1D.linear_table(5, np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]))

1382 array([[-0.1, -0.2, -0.4],

1383 [ 0.3, 0.6, 1.2],

1384 [ 0.7, 1.4, 2.8],

1385 [ 1.1, 2.2, 4.4],

1386 [ 1.5, 3. , 6. ]])

1387 >>> LUT3x1D.linear_table(

1388 ... np.array([5, 3, 2]),

1389 ... np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]),

1390 ... )

1391 array([[-0.1, -0.2, -0.4],

1392 [ 0.3, 1.4, 6. ],

1393 [ 0.7, 3. , nan],

1394 [ 1.1, nan, nan],

1395 [ 1.5, nan, nan]])

1396 >>> domain = np.array(

1397 ... [

1398 ... [-0.1, -0.2, -0.4],

1399 ... [0.3, 1.4, 6.0],

1400 ... [0.7, 3.0, np.nan],

1401 ... [1.1, np.nan, np.nan],

1402 ... [1.5, np.nan, np.nan],

1403 ... ]

1404 ... )

1405 >>> LUT3x1D.linear_table(domain=domain)

1406 array([[-0.1, -0.2, -0.4],

1407 [ 0.3, 1.4, 6. ],

1408 [ 0.7, 3. , nan],

1409 [ 1.1, nan, nan],

1410 [ 1.5, nan, nan]])

1411 """

1412

1413 size = optional(size, 10)

1414 domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]]))

1415

1416 if domain.shape != (2, 3):

1417 return domain

1418

1419 size_array = np.tile(size, 3) if is_numeric(size) else as_int_array(size)

1420

1421 R, G, B = tsplit(domain)

1422

1423 samples = [

1424 np.linspace(a[0], a[1], size_array[i]) for i, a in enumerate([R, G, B])

1425 ]

1426

1427 if len(np.unique(size_array)) != 1:

1428 runtime_warning(

1429 'Table is non uniform, axis will be padded with "NaNs" accordingly!'

1430 )

1431

1432 samples = [

1433 np.pad(

1434 axis,

1435 (0, np.max(size_array) - len(axis)), # pyright: ignore

1436 mode="constant",

1437 constant_values=np.nan,

1438 )

1439 for axis in samples

1440 ]

1441

1442 return tstack(samples)

1443

1444 def invert(self, **kwargs: Any) -> LUT3x1D: # noqa: ARG002

1445 """

1446 Compute and return an inverse copy of the *LUT*.

1447

1448 Other Parameters

1449 ----------------

1450 kwargs

1451 Keywords arguments, only specified for signature compatibility with

1452 the :meth:`AbstractLUT.invert` method.

1453

1454 Returns

1455 -------

1456 :class:`colour.LUT3x1D`

1457 Inverse *LUT* class instance.

1458

1459 Examples

1460 --------

1461 >>> LUT = LUT3x1D(LUT3x1D.linear_table() ** (1 / 2.2))

1462 >>> print(LUT.table)

1463 [[ 0. 0. 0. ]

1464 [ 0.36834383 0.36834383 0.36834383]

1465 [ 0.50476034 0.50476034 0.50476034]

1466 [ 0.60691337 0.60691337 0.60691337]

1467 [ 0.69169882 0.69169882 0.69169882]

1468 [ 0.76553851 0.76553851 0.76553851]

1469 [ 0.83168433 0.83168433 0.83168433]

1470 [ 0.89204934 0.89204934 0.89204934]

1471 [ 0.94787016 0.94787016 0.94787016]

1472 [ 1. 1. 1. ]]

1473 >>> print(LUT.invert()) # doctest: +ELLIPSIS

1474 LUT3x1D - ... - Inverse

1475 ----------...----------

1476 <BLANKLINE>

1477 Dimensions : 2

1478 Domain : [[ 0. ... 0. ... 0. ...]

1479 [ 0.3683438... 0.3683438... 0.3683438...]

1480 [ 0.5047603... 0.5047603... 0.5047603...]

1481 [ 0.6069133... 0.6069133... 0.6069133...]

1482 [ 0.6916988... 0.6916988... 0.6916988...]

1483 [ 0.7655385... 0.7655385... 0.7655385...]

1484 [ 0.8316843... 0.8316843... 0.8316843...]

1485 [ 0.8920493... 0.8920493... 0.8920493...]

1486 [ 0.9478701... 0.9478701... 0.9478701...]

1487 [ 1. ... 1. ... 1. ...]]

1488 Size : (10, 3)

1489 >>> print(LUT.invert().table) # doctest: +ELLIPSIS

1490 [[ 0. ... 0. ... 0. ...]

1491 [ 0.1111111... 0.1111111... 0.1111111...]

1492 [ 0.2222222... 0.2222222... 0.2222222...]

1493 [ 0.3333333... 0.3333333... 0.3333333...]

1494 [ 0.4444444... 0.4444444... 0.4444444...]

1495 [ 0.5555555... 0.5555555... 0.5555555...]

1496 [ 0.6666666... 0.6666666... 0.6666666...]

1497 [ 0.7777777... 0.7777777... 0.7777777...]

1498 [ 0.8888888... 0.8888888... 0.8888888...]

1499 [ 1. ... 1. ... 1. ...]]

1500 """

1501

1502 size = self.table.size // 3

1503 if self.is_domain_explicit():

1504 domain = [

1505 axes[: (~np.isnan(axes)).cumsum().argmax() + 1]

1506 for axes in np.transpose(self.domain)

1507 ]

1508 else:

1509 domain_min, domain_max = self.domain

1510 domain = [np.linspace(domain_min[i], domain_max[i], size) for i in range(3)]

1511

1512 return LUT3x1D(

1513 table=tstack(domain),

1514 name=f"{self.name} - Inverse",

1515 domain=self.table,

1516 )

1517

1518 def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat:

1519 """

1520 Apply the *LUT* to the specified *RGB* colourspace array using the

1521 specified method.

1522

1523 Parameters

1524 ----------

1525 RGB

1526 *RGB* colourspace array to apply the *LUT* onto.

1527

1528 Other Parameters

1529 ----------------

1530 direction

1531 Whether the *LUT* should be applied in the forward or inverse

1532 direction.

1533 extrapolator

1534 Extrapolator class type or object to use as extrapolating

1535 function.

1536 extrapolator_kwargs

1537 Arguments to use when instantiating or calling the extrapolating

1538 function.

1539 interpolator

1540 Interpolator class type to use as interpolating function.

1541 interpolator_kwargs

1542 Arguments to use when instantiating the interpolating function.

1543

1544 Returns

1545 -------

1546 :class:`numpy.ndarray`

1547 Interpolated *RGB* colourspace array.

1548

1549 Examples

1550 --------

1551 >>> LUT = LUT3x1D(LUT3x1D.linear_table() ** (1 / 2.2))

1552 >>> RGB = np.array([0.18, 0.18, 0.18])

1553 >>> LUT.apply(RGB) # doctest: +ELLIPSIS

1554 array([ 0.4529220..., 0.4529220..., 0.4529220...])

1555 >>> LUT.apply(LUT.apply(RGB), direction="Inverse")

1556 ... # doctest: +ELLIPSIS

1557 array([ 0.18..., 0.18..., 0.18...])

1558 >>> from colour.algebra import spow

1559 >>> domain = np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]])

1560 >>> table = spow(LUT3x1D.linear_table(domain=domain), 1 / 2.2)

1561 >>> LUT = LUT3x1D(table, domain=domain)

1562 >>> RGB = np.array([0.18, 0.18, 0.18])

1563 >>> LUT.apply(RGB) # doctest: +ELLIPSIS

1564 array([ 0.4423903..., 0.4503801..., 0.3581625...])

1565 >>> domain = np.array(

1566 ... [

1567 ... [-0.1, -0.2, -0.4],

1568 ... [0.3, 1.4, 6.0],

1569 ... [0.7, 3.0, np.nan],

1570 ... [1.1, np.nan, np.nan],

1571 ... [1.5, np.nan, np.nan],

1572 ... ]

1573 ... )

1574 >>> table = spow(LUT3x1D.linear_table(domain=domain), 1 / 2.2)

1575 >>> LUT = LUT3x1D(table, domain=domain)

1576 >>> RGB = np.array([0.18, 0.18, 0.18])

1577 >>> LUT.apply(RGB) # doctest: +ELLIPSIS

1578 array([ 0.2996370..., -0.0901332..., -0.3949770...])

1579 """

1580

1581 direction = validate_method(

1582 kwargs.get("direction", "Forward"), ("Forward", "Inverse")

1583 )

1584

1585 interpolator = kwargs.get("interpolator", LinearInterpolator)

1586 interpolator_kwargs = kwargs.get("interpolator_kwargs", {})

1587 extrapolator = kwargs.get("extrapolator", Extrapolator)

1588 extrapolator_kwargs = kwargs.get("extrapolator_kwargs", {})

1589

1590 R, G, B = tsplit(RGB)

1591

1592 LUT = self.invert() if direction == "inverse" else self

1593

1594 size = LUT.table.size // 3

1595 if LUT.is_domain_explicit():

1596 samples = [

1597 axes[: (~np.isnan(axes)).cumsum().argmax() + 1]

1598 for axes in np.transpose(LUT.domain)

1599 ]

1600 R_t, G_t, B_t = (

1601 axes[: len(samples[i])]

1602 for i, axes in enumerate(np.transpose(LUT.table))

1603 )

1604 else:

1605 domain_min, domain_max = LUT.domain

1606 samples = [

1607 np.linspace(domain_min[i], domain_max[i], size) for i in range(3)

1608 ]

1609 R_t, G_t, B_t = tsplit(LUT.table)

1610

1611 s_R, s_G, s_B = samples

1612

1613 RGB_i = [

1614 extrapolator(

1615 interpolator(a[0], a[1], **interpolator_kwargs),

1616 **extrapolator_kwargs,

1617 )(a[2])

1618 for a in zip((s_R, s_G, s_B), (R_t, G_t, B_t), (R, G, B), strict=True)

1619 ]

1620

1621 return tstack(RGB_i)

1622

1623

1624 class LUT3D(AbstractLUT):

1625 """

1626 Define the base class for a 3-dimensional lookup table (3D *LUT*).

1627

1628 This class provides a foundation for working with 3D lookup tables,

1629 which map input colour values through a discretized 3D grid to output

1630 colour values. The table operates on three input channels

1631 simultaneously, making it suitable for RGB-to-RGB colour

1632 transformations and other tristimulus colour space operations.

1633

1634 Parameters

1635 ----------

1636 table

1637 Underlying *LUT* table.

1638 name

1639 *LUT* name.

1640 domain

1641 *LUT* domain, also used to define the instantiation time default

1642 table domain.

1643 size

1644 Size of the instantiation time default table, default to 33.

1645 comments

1646 Comments to add to the *LUT*.

1647

1648 Methods

1649 -------

1650 - :meth:`~colour.LUT3D.__init__`

1651 - :meth:`~colour.LUT3D.is_domain_explicit`

1652 - :meth:`~colour.LUT3D.linear_table`

1653 - :meth:`~colour.LUT3D.invert`

1654 - :meth:`~colour.LUT3D.apply`

1655

1656 Examples

1657 --------

1658 Instantiating a unity LUT with a table with 16x16x16x3 elements:

1659

1660 >>> print(LUT3D(size=16))

1661 LUT3D - Unity 16

1662 ----------------

1663 <BLANKLINE>

1664 Dimensions : 3

1665 Domain : [[ 0. 0. 0.]

1666 [ 1. 1. 1.]]

1667 Size : (16, 16, 16, 3)

1668

1669 Instantiating a LUT using a custom table with 16x16x16x3 elements:

1670

1671 >>> print(LUT3D(LUT3D.linear_table(16) ** (1 / 2.2))) # doctest: +ELLIPSIS

1672 LUT3D - ...

1673 --------...

1674 <BLANKLINE>

1675 Dimensions : 3

1676 Domain : [[ 0. 0. 0.]

1677 [ 1. 1. 1.]]

1678 Size : (16, 16, 16, 3)

1679

1680 Instantiating a LUT using a custom table with 16x16x16x3 elements,

1681 custom name, custom domain and comments:

1682

1683 >>> from colour.algebra import spow

1684 >>> domain = np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]])

1685 >>> print(

1686 ... LUT3D(

1687 ... spow(LUT3D.linear_table(16), 1 / 2.2),

1688 ... "My LUT",

1689 ... domain,

1690 ... comments=["A first comment.", "A second comment."],

1691 ... )

1692 ... )

1693 LUT3D - My LUT

1694 --------------

1695 <BLANKLINE>

1696 Dimensions : 3

1697 Domain : [[-0.1 -0.2 -0.4]

1698 [ 1.5 3. 6. ]]

1699 Size : (16, 16, 16, 3)

1700 Comment 01 : A first comment.

1701 Comment 02 : A second comment.

1702 """

1703

1704 def __init__(

1705 self,

1706 table: ArrayLike | None = None,

1707 name: str | None = None,

1708 domain: ArrayLike | None = None,

1709 size: ArrayLike | None = None,

1710 comments: Sequence | None = None,

1711 ) -> None:

1712 domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]]))

1713 size = optional(size, 33)

1714

1715 super().__init__(table, name, 3, domain, size, comments)

1716

1717 def _validate_table(self, table: ArrayLike) -> NDArrayFloat:

1718 """

1719 Validate that the specified table is a 4D array with equal

1720 dimensions.

1721

1722 Parameters

1723 ----------

1724 table

1725 Table to validate.

1726

1727 Returns

1728 -------

1729 :class:`numpy.ndarray`

1730 Validated table as a :class:`numpy.ndarray` instance.

1731 """

1732

1733 table = as_float_array(table)

1734

1735 attest(len(table.shape) == 4, "The table must be a 4D array!")

1736

1737 return table

1738

1739 def _validate_domain(self, domain: ArrayLike) -> NDArrayFloat:

1740 """

1741 Validate the specified domain for the 3D lookup table.

1742

1743 Parameters

1744 ----------

1745 domain

1746 Domain array to validate. Must be a 2D array with at least 2

1747 rows and exactly 3 columns.

1748

1749 Returns

1750 -------

1751 :class:`numpy.ndarray`

1752 Validated domain as a :class:`numpy.ndarray` instance.

1753

1754 Notes

1755 -----

1756 - A :class:`LUT3D` class instance must use an implicit domain.

1757 """

1758

1759 domain = as_float_array(domain)

1760

1761 attest(len(domain.shape) == 2, "The domain must be a 2D array!")

1762

1763 attest(

1764 domain.shape[0] >= 2,

1765 "The domain row count must be equal or greater than 2!",

1766 )

1767

1768 attest(domain.shape[1] == 3, "The domain column count must be equal to 3!")

1769

1770 return domain

1771

1772 def is_domain_explicit(self) -> bool:

1773 """

1774 Return whether the *LUT* domain is explicit (or implicit).

1775

1776 An implicit domain is defined by its shape only::

1777

1778 [[0 0 0]

1779 [1 1 1]]

1780

1781 While an explicit domain defines every single discrete sample::

1782

1783 [[0.0 0.0 0.0]

1784 [0.1 0.1 0.1]

1785 [0.2 0.2 0.2]

1786 [0.3 0.3 0.3]

1787 [0.4 0.4 0.4]

1788 [0.8 0.8 0.8]

1789 [1.0 1.0 1.0]]

1790

1791 Returns

1792 -------

1793 :class:`bool`

1794 Is *LUT* domain explicit.

1795

1796 Examples

1797 --------

1798 >>> LUT3D().is_domain_explicit()

1799 False

1800 >>> domain = np.array([[-0.1, -0.2, -0.4], [0.7, 1.4, 6.0], [1.5, 3.0, np.nan]])

1801 >>> LUT3D(domain=domain).is_domain_explicit()

1802 True

1803 """

1804

1805 return self.domain.shape != (2, 3)

1806

1807 @staticmethod

1808 def linear_table(

1809 size: ArrayLike | None = None,

1810 domain: ArrayLike | None = None,

1811 ) -> NDArrayFloat:

1812 """

1813 Generate a linear table with the specified size and domain.

1814

1815 The number of output samples :math:`n` is equal to ``size**3 * 3`` or

1816 ``size[0] * size[1] * size[2] * 3``.

1817

1818 Parameters

1819 ----------

1820 size

1821 Expected table size, default to 33.

1822 domain

1823 Domain of the table.

1824

1825 Returns

1826 -------

1827 :class:`numpy.ndarray`

1828 Linear table with ``size**3 * 3`` or

1829 ``size[0] * size[1] * size[2] * 3`` samples.

1830

1831 Examples

1832 --------

1833 >>> LUT3D.linear_table(3, np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]))

1834 array([[[[-0.1, -0.2, -0.4],

1835 [-0.1, -0.2, 2.8],

1836 [-0.1, -0.2, 6. ]],

1837 <BLANKLINE>

1838 [[-0.1, 1.4, -0.4],

1839 [-0.1, 1.4, 2.8],

1840 [-0.1, 1.4, 6. ]],

1841 <BLANKLINE>

1842 [[-0.1, 3. , -0.4],

1843 [-0.1, 3. , 2.8],

1844 [-0.1, 3. , 6. ]]],

1845 <BLANKLINE>

1846 <BLANKLINE>

1847 [[[ 0.7, -0.2, -0.4],

1848 [ 0.7, -0.2, 2.8],

1849 [ 0.7, -0.2, 6. ]],

1850 <BLANKLINE>

1851 [[ 0.7, 1.4, -0.4],

1852 [ 0.7, 1.4, 2.8],

1853 [ 0.7, 1.4, 6. ]],

1854 <BLANKLINE>

1855 [[ 0.7, 3. , -0.4],

1856 [ 0.7, 3. , 2.8],

1857 [ 0.7, 3. , 6. ]]],

1858 <BLANKLINE>

1859 <BLANKLINE>

1860 [[[ 1.5, -0.2, -0.4],

1861 [ 1.5, -0.2, 2.8],

1862 [ 1.5, -0.2, 6. ]],

1863 <BLANKLINE>

1864 [[ 1.5, 1.4, -0.4],

1865 [ 1.5, 1.4, 2.8],

1866 [ 1.5, 1.4, 6. ]],

1867 <BLANKLINE>

1868 [[ 1.5, 3. , -0.4],

1869 [ 1.5, 3. , 2.8],

1870 [ 1.5, 3. , 6. ]]]])

1871 >>> LUT3D.linear_table(

1872 ... np.array([3, 3, 2]),

1873 ... np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]]),

1874 ... )

1875 array([[[[-0.1, -0.2, -0.4],

1876 [-0.1, -0.2, 6. ]],

1877 <BLANKLINE>

1878 [[-0.1, 1.4, -0.4],

1879 [-0.1, 1.4, 6. ]],

1880 <BLANKLINE>

1881 [[-0.1, 3. , -0.4],

1882 [-0.1, 3. , 6. ]]],

1883 <BLANKLINE>

1884 <BLANKLINE>

1885 [[[ 0.7, -0.2, -0.4],

1886 [ 0.7, -0.2, 6. ]],

1887 <BLANKLINE>

1888 [[ 0.7, 1.4, -0.4],

1889 [ 0.7, 1.4, 6. ]],

1890 <BLANKLINE>

1891 [[ 0.7, 3. , -0.4],

1892 [ 0.7, 3. , 6. ]]],

1893 <BLANKLINE>

1894 <BLANKLINE>

1895 [[[ 1.5, -0.2, -0.4],

1896 [ 1.5, -0.2, 6. ]],

1897 <BLANKLINE>

1898 [[ 1.5, 1.4, -0.4],

1899 [ 1.5, 1.4, 6. ]],

1900 <BLANKLINE>

1901 [[ 1.5, 3. , -0.4],

1902 [ 1.5, 3. , 6. ]]]])

1903 >>> domain = np.array([[-0.1, -0.2, -0.4], [0.7, 1.4, 6.0], [1.5, 3.0, np.nan]])

1904 >>> LUT3D.linear_table(domain=domain)

1905 array([[[[-0.1, -0.2, -0.4],

1906 [-0.1, -0.2, 6. ]],

1907 <BLANKLINE>

1908 [[-0.1, 1.4, -0.4],

1909 [-0.1, 1.4, 6. ]],

1910 <BLANKLINE>

1911 [[-0.1, 3. , -0.4],

1912 [-0.1, 3. , 6. ]]],

1913 <BLANKLINE>

1914 <BLANKLINE>

1915 [[[ 0.7, -0.2, -0.4],

1916 [ 0.7, -0.2, 6. ]],

1917 <BLANKLINE>

1918 [[ 0.7, 1.4, -0.4],

1919 [ 0.7, 1.4, 6. ]],

1920 <BLANKLINE>

1921 [[ 0.7, 3. , -0.4],

1922 [ 0.7, 3. , 6. ]]],

1923 <BLANKLINE>

1924 <BLANKLINE>

1925 [[[ 1.5, -0.2, -0.4],

1926 [ 1.5, -0.2, 6. ]],

1927 <BLANKLINE>

1928 [[ 1.5, 1.4, -0.4],

1929 [ 1.5, 1.4, 6. ]],

1930 <BLANKLINE>

1931 [[ 1.5, 3. , -0.4],

1932 [ 1.5, 3. , 6. ]]]])

1933 """

1934

1935 size = optional(size, 33)

1936 domain = as_float_array(optional(domain, [[0, 0, 0], [1, 1, 1]]))

1937

1938 if domain.shape != (2, 3):

1939 samples = list(

1940 np.flip(

1941 # NOTE: "dtype=object" is required for ragged array support

1942 # in "Numpy" 1.24.0.

1943 as_array(

1944 [

1945 axes[: (~np.isnan(axes)).cumsum().argmax() + 1]

1946 for axes in np.transpose(domain)

1947 ],

1948 dtype=object, # pyright: ignore

1949 ),

1950 -1,

1951 )

1952 )

1953 size_array = as_int_array([len(axes) for axes in samples])

1954 else:

1955 size_array = np.tile(size, 3) if is_numeric(size) else as_int_array(size)

1956

1957 R, G, B = tsplit(domain)

1958

1959 size_array = np.flip(size_array, -1)

1960 samples = [

1961 np.linspace(a[0], a[1], size_array[i]) for i, a in enumerate([B, G, R])

1962 ]

1963

1964 return np.flip(

1965 np.reshape(

1966 np.transpose(np.meshgrid(*samples, indexing="ij")),

1967 np.hstack([np.flip(size_array, -1), 3]),

1968 ),

1969 -1,

1970 )

1971

1972 @required("SciPy")

1973 def invert(self, **kwargs: Any) -> LUT3D:

1974 """

1975 Compute and return an inverse copy of the *LUT*.

1976

1977 Other Parameters

1978 ----------------

1979 interpolator

1980 Interpolator class type or object to use as interpolating

1981 function.

1982 query_size

1983 Number of nearest neighbors to use for Shepard interpolation

1984 (inverse distance weighting). Default is 8, optimized for speed and

1985 quality. Higher values (16-32) may slightly improve smoothness but

1986 significantly increase computation time.

1987 gamma

1988 Gradient smoothness parameter for Shepard interpolation. Default is

1989 3.0 (optimized for smoothness). Controls the weight falloff rate in

1990 inverse distance weighting (:math:`w_i = 1/d_i^{1/gamma}`). Higher

1991 gamma values produce smoother gradients.

1992

1993 - Default (3.0): Optimal smoothness with minimal artifacts

1994 - Lower values (1.5-2.0): Sharper transitions, faster computation,

1995 may increase banding artifacts

1996 - Very low values (0.5-1.0): Maximum sharpness, more localized

1997 interpolation, higher banding risk

1998 sigma

1999 Gaussian blur sigma for iterative adaptive smoothing.

2000 Default is 0.7. Smoothing is applied iteratively only to

2001 high-gradient regions (banding artifacts) identified using the

2002 percentile threshold, preserving quality in smooth regions.

2003

2004 - Default (0.7): Optimal smoothing - reduces banding by ~38%

2005 (26 → 16 artifacts) while preserving corners

2006 - Higher values (0.8-0.9): More aggressive, may increase corner shift

2007 - Lower values (0.5-0.6): Gentler smoothing, better corner preservation

2008 - Set to 0.0 to disable adaptive smoothing entirely

2009

2010 The iterative adaptive approach with gradient recomputation ensures

2011 clean LUTs remain unaffected while problematic regions receive

2012 targeted smoothing.

2013 tau

2014 Percentile threshold for identifying high-gradient regions (0-1).

2015 Default is 0.75 (75th percentile). Higher values mean fewer regions

2016 are smoothed (more selective), lower values mean more regions are

2017 smoothed (more aggressive).

2018

2019 - Default (0.75): Smooths top 25% of gradient regions

2020 - Higher values (0.85-0.95): Very selective, minimal smoothing

2021 - Lower values (0.50-0.65): More aggressive, smooths more regions

2022

2023 Only used when sigma > 0.

2024 iterations

2025 Number of iterative smoothing passes. Default is 10.

2026 Each iteration recomputes gradients and adapts smoothing to the

2027 evolving LUT state, providing better artifact reduction than a

2028 single strong blur.

2029

2030 - Default (10): Optimal balance of quality and performance

2031 - Higher values (12-15): Slightly better artifact reduction, slower

2032 - Lower values (5-7): Faster, but fewer artifacts removed

2033

2034 Only used when sigma > 0.

2035 oversampling

2036 Oversampling factor for building the KDTree. Default is 1.2.

2037 The optimal value is based on Jacobian analysis of the LUT

2038 transformation: the Jacobian matrix

2039 :math:`J = \\partial(output)/\\partial(input)` measures local

2040 volume distortion. When :math:`|J| < 1`, the LUT compresses space,

2041 requiring higher sampling density for accurate inversion.

2042 The factor 1.2 captures approximately 80% of the theoretical

2043 accuracy benefit at 30% of the computational cost. Values between

2044 1.0 (no oversampling) and 2.0 (diminishing returns) are supported.

2045 size

2046 Size of the inverse *LUT*. With the specified implementation,

2047 it is good practise to double the size of the inverse *LUT* to

2048 provide a smoother result. If ``size`` is not specified,

2049 :math:`2^{\\sqrt{size_{LUT}} + 1} + 1` will be used instead.

2050

2051 Returns

2052 -------

2053 :class:`colour.LUT3D`

2054 Inverse *LUT* class instance.

2055

2056 Examples

2057 --------

2058 >>> LUT = LUT3D()

2059 >>> print(LUT)

2060 LUT3D - Unity 33

2061 ----------------

2062 <BLANKLINE>

2063 Dimensions : 3

2064 Domain : [[ 0. 0. 0.]

2065 [ 1. 1. 1.]]

2066 Size : (33, 33, 33, 3)

2067 >>> print(LUT.invert())

2068 LUT3D - Unity 33 - Inverse

2069 --------------------------

2070 <BLANKLINE>

2071 Dimensions : 3

2072 Domain : [[ 0. 0. 0.]

2073 [ 1. 1. 1.]]

2074 Size : (108, 108, 108, 3)

2075 """

2076

2077 from scipy.ndimage import gaussian_filter # noqa: PLC0415

2078 from scipy.spatial import KDTree # noqa: PLC0415

2079

2080 if self.is_domain_explicit():

2081 error = 'Inverting a "LUT3D" with an explicit domain is not implemented!'

2082

2083 raise NotImplementedError(error)

2084

2085 interpolator = kwargs.get("interpolator", table_interpolation_trilinear)

2086 query_size = kwargs.get("query_size", 8)

2087 gamma = kwargs.get("gamma", 3.0)

2088 sigma = kwargs.get("sigma", 0.7)

2089 tau = kwargs.get("tau", 0.75)

2090 oversampling = kwargs.get("oversampling", 1.2)

2091

2092 LUT = self.copy()

2093 source_size = LUT.size

2094 target_size = kwargs.get("size", (as_int(2 ** (np.sqrt(source_size) + 1) + 1)))

2095 sampling_size = int(target_size * oversampling)

2096

2097 if target_size > 129: # pragma: no cover

2098 usage_warning("LUT3D inverse computation time could be excessive!")

2099

2100 # "LUT_t" is an intermediate LUT with oversampling to better capture

2101 # the LUT's transformation, especially in regions with high compression.

2102 # Sampling factor of 1.2 is based on Jacobian analysis: captures 80%

2103 # of theoretical benefit at 30% of computational cost.

2104 LUT_t = LUT3D(size=sampling_size, domain=LUT.domain)

2105 table = np.reshape(LUT_t.table, (-1, 3))

2106 LUT_t.table = LUT.apply(LUT_t.table, interpolator=interpolator)

2107

2108 tree = KDTree(np.reshape(LUT_t.table, (-1, 3)))

2109

2110 # "LUT_q" stores the inverse LUT with improved interpolation.

2111 # Query at the target resolution (output size).

2112 LUT_q = LUT3D(size=target_size, domain=LUT.domain)

2113 query_points = np.reshape(LUT_q.table, (-1, 3))

2114

2115 distances, indices = tree.query(query_points, query_size)

2116

2117 if query_size == 1:

2118 # Single nearest neighbor - no interpolation needed

2119 LUT_q.table = np.reshape(

2120 table[indices], (target_size, target_size, target_size, 3)

2121 )

2122 else:

2123 # Shepard's method (inverse distance weighting) for smooth interpolation.

2124 # Uses w_i = 1 / d_i^(1/gamma) where gamma controls the falloff rate.

2125 # Higher gamma (e.g., 2.0-4.0) creates smoother gradients by blending more

2126 # globally, while lower gamma (e.g., 0.25-0.5) creates sharper transitions.

2127 power = 1.0 / gamma

2128 distances = cast("NDArrayFloat", distances)

2129 weights = 1.0 / (distances + EPSILON) ** power

2130 weights = weights / np.sum(weights, axis=1, keepdims=True)

2131

2132 # Weighted average: sum over neighbors dimension

2133 weighted_table = np.sum(table[indices] * weights[..., np.newaxis], axis=1)

2134

2135 LUT_q.table = np.reshape(

2136 weighted_table,

2137 (target_size, target_size, target_size, 3),

2138 )

2139

2140 # Apply iterative adaptive smoothing based on gradient magnitude.

2141 # Smooths only high-gradient regions (banding artifacts) while preserving

2142 # quality in smooth regions. Multiple iterations with gradient recomputation

2143 # allow smoothing to adapt as the LUT evolves.

2144 if sigma > 0:

2145

2146 def extrapolate(data_3d: NDArrayFloat, pad_width: int) -> NDArrayFloat:

2147 """

2148 Pad the 3D array with linear extrapolation based on edge gradients.

2149

2150 For each axis, extrapolate using:

2151 value[edge + i] = value[edge] + i * gradient

2152

2153 This preserves boundary values much better than reflect/mirror modes.

2154 """

2155

2156 result = data_3d

2157

2158 for axis in range(3):

2159 # Compute edge gradients

2160 edge_lo = np.take(result, [0], axis=axis)

2161 edge_hi = np.take(result, [-1], axis=axis)

2162 grad_lo = edge_lo - np.take(result, [1], axis=axis)

2163 grad_hi = edge_hi - np.take(result, [-2], axis=axis)

2164

2165 # Create padding using linear extrapolation

2166 pad_lo = [edge_lo + (i + 1) * grad_lo for i in range(pad_width)]

2167 pad_hi = [edge_hi + (i + 1) * grad_hi for i in range(pad_width)]

2168

2169 # Concatenate (reverse low padding)

2170 result = np.concatenate([*pad_lo[::-1], result, *pad_hi], axis=axis)

2171

2172 return result

2173

2174 # Iterative smoothing: apply multiple passes with gradient recomputation.

2175 # Each iteration adapts to the evolving LUT state, providing better

2176 # artifact reduction than a single strong blur.

2177 iterations = kwargs.get("iterations", 10)

2178 pad_width = 10

2179

2180 for _ in range(iterations):

2181 # Recompute gradient magnitude at each iteration to adapt

2182 # to the current LUT state

2183 gradient_magnitude = np.zeros(LUT_q.table.shape[:3])

2184

2185 for i in range(3):

2186 gx = np.gradient(LUT_q.table[..., i], axis=0)

2187 gy = np.gradient(LUT_q.table[..., i], axis=1)

2188 gz = np.gradient(LUT_q.table[..., i], axis=2)

2189

2190 gradient_magnitude += np.sqrt(gx**2 + gy**2 + gz**2)

2191

2192 gradient_magnitude /= 3.0

2193

2194 # Identify high-gradient regions using percentile threshold

2195 threshold = np.percentile(gradient_magnitude, tau * 100)

2196

2197 # Apply Gaussian blur with linear extrapolation padding

2198 for i in range(3):

2199 # Pad with linear extrapolation (recomputed each iteration)

2200 table_p = extrapolate(LUT_q.table[..., i], pad_width)

2201 # Filter the padded data

2202 table_f = gaussian_filter(table_p, sigma=sigma)

2203 # Un-pad

2204 table_e = table_f[

2205 pad_width:-pad_width,

2206 pad_width:-pad_width,

2207 pad_width:-pad_width,

2208 ]

2209 # Apply selectively to high-gradient regions only

2210 LUT_q.table[..., i] = np.where(

2211 gradient_magnitude > threshold,

2212 table_e,

2213 LUT_q.table[..., i],

2214 )

2215

2216 LUT_q.name = f"{self.name} - Inverse"

2217

2218 return LUT_q

2219

2220 def apply(self, RGB: ArrayLike, **kwargs: Any) -> NDArrayFloat:

2221 """

2222 Apply the *LUT* to the specified *RGB* colourspace array using the

2223 specified interpolation method.

2224

2225 Parameters

2226 ----------

2227 RGB

2228 *RGB* colourspace array to apply the *LUT* onto.

2229

2230 Other Parameters

2231 ----------------

2232 direction

2233 Whether the *LUT* should be applied in the forward or inverse

2234 direction.

2235 interpolator

2236 Interpolator object to use as the interpolating function.

2237 interpolator_kwargs

2238 Arguments to use when calling the interpolating function.

2239 query_size

2240 Number of points to query in the KDTree, with their mean

2241 computed to produce a smoother result.

2242 size

2243 Size of the inverse *LUT*. With the specified implementation,

2244 it is recommended to double the size of the inverse *LUT* to

2245 provide a smoother result. If ``size`` is not specified,

2246 :math:`2^{\\sqrt{size_{LUT}} + 1} + 1` will be used instead.

2247

2248 Returns

2249 -------

2250 :class:`numpy.ndarray`

2251 Interpolated *RGB* colourspace array.

2252

2253 Examples

2254 --------

2255 >>> LUT = LUT3D(LUT3D.linear_table() ** (1 / 2.2))

2256 >>> RGB = np.array([0.18, 0.18, 0.18])

2257 >>> LUT.apply(RGB) # doctest: +ELLIPSIS

2258 array([ 0.4583277..., 0.4583277..., 0.4583277...])

2259 >>> LUT.apply(LUT.apply(RGB), direction="Inverse")

2260 ... # doctest: +ELLIPSIS +SKIP

2261 array([ 0.1799897..., 0.1796077..., 0.1795868...])

2262 >>> from colour.algebra import spow

2263 >>> domain = np.array(

2264 ... [

2265 ... [-0.1, -0.2, -0.4],

2266 ... [0.3, 1.4, 6.0],

2267 ... [0.7, 3.0, np.nan],

2268 ... [1.1, np.nan, np.nan],

2269 ... [1.5, np.nan, np.nan],

2270 ... ]

2271 ... )

2272 >>> table = spow(LUT3D.linear_table(domain=domain), 1 / 2.2)

2273 >>> LUT = LUT3D(table, domain=domain)

2274 >>> RGB = np.array([0.18, 0.18, 0.18])

2275 >>> LUT.apply(RGB) # doctest: +ELLIPSIS

2276 array([ 0.2996370..., -0.0901332..., -0.3949770...])

2277 """

2278

2279 direction = validate_method(

2280 kwargs.get("direction", "Forward"), ("Forward", "Inverse")

2281 )

2282

2283 interpolator = kwargs.get("interpolator", table_interpolation_trilinear)

2284 interpolator_kwargs = kwargs.get("interpolator_kwargs", {})

2285

2286 R, G, B = tsplit(RGB)

2287

2288 settings = {"interpolator": interpolator}

2289 settings.update(**kwargs)

2290 LUT = self.invert(**settings) if direction == "inverse" else self

2291

2292 if LUT.is_domain_explicit():

2293 domain_min = LUT.domain[0, ...]

2294 domain_max = [

2295 axes[: (~np.isnan(axes)).cumsum().argmax() + 1][-1]

2296 for axes in np.transpose(LUT.domain)

2297 ]

2298 usage_warning(

2299 f'"LUT" was defined with an explicit domain but requires an '

2300 f"implicit domain to be applied. The following domain will be "

2301 f"used: {np.vstack([domain_min, domain_max])}"

2302 )

2303 else:

2304 domain_min, domain_max = LUT.domain

2305

2306 RGB_l = [

2307 linear_conversion(j, (domain_min[i], domain_max[i]), (0, 1))

2308 for i, j in enumerate((R, G, B))

2309 ]

2310

2311 return interpolator(tstack(RGB_l), LUT.table, **interpolator_kwargs)

2312

2313

2314 def LUT_to_LUT(

2315 LUT: AbstractLUT,

2316 cls: Type[AbstractLUT],

2317 force_conversion: bool = False,

2318 **kwargs: Any,

2319) -> AbstractLUT:

2320 """

2321 Convert a specified *LUT* to the specified ``cls`` class instance.

2322

2323 This function facilitates conversion between different LUT class types,

2324 including LUT1D, LUT3x1D, and LUT3D instances. Some conversions may be

2325 destructive and require explicit force conversion.

2326

2327 Parameters

2328 ----------

2329 LUT

2330 *LUT* to convert.

2331 cls

2332 Target *LUT* class type for conversion.

2333 force_conversion

2334 Whether to force the conversion if it would be destructive.

2335

2336 Other Parameters

2337 ----------------

2338 channel_weights

2339 Channel weights in case of a downcast from a :class:`LUT3x1D` or

2340 :class:`LUT3D` class instance.

2341 interpolator

2342 Interpolator class type to use as interpolating function.

2343 interpolator_kwargs

2344 Arguments to use when instantiating the interpolating function.

2345 size

2346 Expected table size in case of an upcast to or a downcast from a

2347 :class:`LUT3D` class instance.

2348

2349 Returns

2350 -------

2351 :class:`colour.LUT1D` or :class:`colour.LUT3x1D` or :class:`colour.LUT3D`

2352 Converted *LUT* class instance.

2353

2354 Warnings

2355 --------

2356 Some conversions are destructive and raise a :class:`ValueError` exception

2357 by default.

2358

2359 Raises

2360 ------

2361 ValueError

2362 If the conversion is destructive.

2363

2364 Examples

2365 --------

2366 >>> print(LUT_to_LUT(LUT1D(), LUT3D, force_conversion=True))

2367 LUT3D - Unity 10 - Converted 1D to 3D

2368 -------------------------------------

2369 <BLANKLINE>

2370 Dimensions : 3

2371 Domain : [[ 0. 0. 0.]

2372 [ 1. 1. 1.]]

2373 Size : (33, 33, 33, 3)

2374 >>> print(LUT_to_LUT(LUT3x1D(), LUT1D, force_conversion=True))

2375 LUT1D - Unity 10 - Converted 3x1D to 1D

2376 ---------------------------------------

2377 <BLANKLINE>

2378 Dimensions : 1

2379 Domain : [ 0. 1.]

2380 Size : (10,)

2381 >>> print(LUT_to_LUT(LUT3D(), LUT1D, force_conversion=True))

2382 LUT1D - Unity 33 - Converted 3D to 1D

2383 -------------------------------------

2384 <BLANKLINE>

2385 Dimensions : 1

2386 Domain : [ 0. 1.]

2387 Size : (10,)

2388 """

2389

2390 ranks = {LUT1D: 1, LUT3x1D: 2, LUT3D: 3}

2391 path = (ranks[LUT.__class__], ranks[cls])

2392 path_verbose = [f"{element}D" if element != 2 else "3x1D" for element in path]

2393 if path in ((1, 3), (2, 1), (2, 3), (3, 1), (3, 2)) and not force_conversion:

2394 error = (

2395 f'Conversion of a "LUT" {path_verbose[0]} to a "LUT" '

2396 f"{path_verbose[1]} is destructive, please use the "

2397 f'"force_conversion" argument to proceed!'

2398 )

2399

2400 raise ValueError(error)

2401

2402 suffix = f" - Converted {path_verbose[0]} to {path_verbose[1]}"

2403 name = f"{LUT.name}{suffix}"

2404

2405 # Same dimension conversion, returning a copy.

2406 if len(set(path)) == 1:

2407 LUT = LUT.copy()

2408 LUT.name = name

2409 else:

2410 size = kwargs.get("size", 33 if cls is LUT3D else 10)

2411 kwargs.pop("size", None)

2412

2413 channel_weights = as_float_array(kwargs.get("channel_weights", full(3, 1 / 3)))

2414 kwargs.pop("channel_weights", None)

2415

2416 if isinstance(LUT, LUT1D):

2417 if cls is LUT3x1D:

2418 domain = tstack([LUT.domain, LUT.domain, LUT.domain])

2419 table = tstack([LUT.table, LUT.table, LUT.table])

2420 elif cls is LUT3D:

2421 domain = tstack([LUT.domain, LUT.domain, LUT.domain])

2422 table = LUT3D.linear_table(size, domain)

2423 table = LUT.apply(table, **kwargs)

2424 elif isinstance(LUT, LUT3x1D):

2425 if cls is LUT1D:

2426 domain = np.sum(LUT.domain * channel_weights, axis=-1)

2427 table = np.sum(LUT.table * channel_weights, axis=-1)

2428 elif cls is LUT3D:

2429 domain = LUT.domain

2430 table = LUT3D.linear_table(size, domain)

2431 table = LUT.apply(table, **kwargs)

2432 elif isinstance(LUT, LUT3D):

2433 if cls is LUT1D:

2434 domain = np.sum(LUT.domain * channel_weights, axis=-1)

2435 table = LUT1D.linear_table(size, domain)

2436 table = LUT.apply(tstack([table, table, table]), **kwargs)

2437 table = np.sum(table * channel_weights, axis=-1)

2438 elif cls is LUT3x1D:

2439 domain = LUT.domain

2440 table = LUT3x1D.linear_table(size, domain)

2441 table = LUT.apply(table, **kwargs)

2442

2443 LUT = cls(

2444 table=table,

2445 name=name,

2446 domain=domain,

2447 size=table.shape[0],

2448 comments=LUT.comments,

2449 )

2450

2451 return LUT