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

370 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-16 22:49 +1300

1""" 

2LUT Processing 

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

4 

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

6operations. 

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 

18from __future__ import annotations 

19 

20import typing 

21from abc import ABC, abstractmethod 

22from copy import deepcopy 

23from operator import pow # noqa: A004 

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

25 

26import numpy as np 

27 

28from colour.algebra import ( 

29 Extrapolator, 

30 LinearInterpolator, 

31 linear_conversion, 

32 table_interpolation_trilinear, 

33) 

34from colour.constants import EPSILON 

35 

36if typing.TYPE_CHECKING: 

37 from colour.hints import ( 

38 Any, 

39 ArrayLike, 

40 Literal, 

41 NDArrayFloat, 

42 Self, 

43 Sequence, 

44 Type, 

45 ) 

46 

47from colour.hints import List, cast 

48from colour.utilities import ( 

49 as_array, 

50 as_float_array, 

51 as_int, 

52 as_int_array, 

53 as_int_scalar, 

54 attest, 

55 full, 

56 is_iterable, 

57 is_numeric, 

58 multiline_repr, 

59 multiline_str, 

60 optional, 

61 required, 

62 runtime_warning, 

63 tsplit, 

64 tstack, 

65 usage_warning, 

66 validate_method, 

67) 

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 

85class 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 

837class 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... \ 

10670.6916988... 0.7655385... 

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

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

1070 LUT1D - ... - Inverse 

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

1072 <BLANKLINE> 

1073 Dimensions : 1 

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

10750.6916988... 0.7655385... 

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

1077 Size : (10,) 

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

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

10800.4444444... 0.5555555... 

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

1082 """ 

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 

1171class 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 

1624class 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 

2314def LUT_to_LUT( 

2315 LUT: AbstractLUT, 

2316 cls: Type[AbstractLUT], 

2317 force_conversion: bool = False, 

2318 **kwargs: Any, 

2319) -> AbstractLUT: 

2320 """ 

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

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