Coverage for utilities/array.py: 72%

1"""

2 Array Utilities

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

4

5 Provide utilities for array manipulation and computational operations.

6

7 References

8----------

9- :cite:`Castro2014a` : Castro, S. (2014). Numpy: Fastest way of computing

10 diagonal for each row of a 2d array. Retrieved August 22, 2014, from

11 http://stackoverflow.com/questions/26511401/\

12 numpy-fastest-way-of-computing-diagonal-for-each-row-of-a-2d-array/\

13 26517247#26517247

14- :cite:`Yorke2014a` : Yorke, R. (2014). Python: Change format of np.array or

15 allow tolerance in in1d function. Retrieved March 27, 2015, from

16 http://stackoverflow.com/a/23521245/931625

17"""

18

19 from __future__ import annotations

20

21 import functools

22 import re

23 import sys

24 import typing

25 from collections.abc import KeysView, ValuesView

26 from contextlib import contextmanager

27 from dataclasses import fields, is_dataclass, replace

28 from operator import add, mul, pow, sub, truediv # noqa: A004

29 from typing import Union, get_args, get_origin, get_type_hints

30

31 import numpy as np

32

33 from colour.constants import (

34 DTYPE_COMPLEX_DEFAULT,

35 DTYPE_FLOAT_DEFAULT,

36 DTYPE_INT_DEFAULT,

37 EPSILON,

38)

39

40 if typing.TYPE_CHECKING:

41 from colour.hints import (

42 Any,

43 Callable,

44 DType,

45 DTypeBoolean,

46 DTypeComplex,

47 DTypeReal,

48 Dataclass,

49 Generator,

50 Literal,

51 NDArray,

52 NDArrayComplex,

53 NDArrayFloat,

54 NDArrayInt,

55 Real,

56 Self,

57 Sequence,

58 Type,

59 )

60

61 from colour.hints import ArrayLike, DTypeComplex, DTypeFloat, DTypeInt, cast

62 from colour.utilities import (

63 CACHE_REGISTRY,

64 attest,

65 int_digest,

66 is_caching_enabled,

67 optional,

68 suppress_warnings,

69 validate_method,

70)

71

72 __author__ = "Colour Developers"

73 __copyright__ = "Copyright 2013 Colour Developers"

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

75 __maintainer__ = "Colour Developers"

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

77 __status__ = "Production"

78

79 __all__ = [

80 "MixinDataclassFields",

81 "MixinDataclassIterable",

82 "MixinDataclassArray",

83 "MixinDataclassArithmetic",

84 "as_array",

85 "as_int",

86 "as_float",

87 "as_int_array",

88 "as_float_array",

89 "as_int_scalar",

90 "as_float_scalar",

91 "as_complex_array",

92 "set_default_int_dtype",

93 "set_default_float_dtype",

94 "get_domain_range_scale",

95 "set_domain_range_scale",

96 "domain_range_scale",

97 "get_domain_range_scale_metadata",

98 "to_domain_1",

99 "to_domain_10",

100 "to_domain_100",

101 "to_domain_degrees",

102 "to_domain_int",

103 "from_range_1",

104 "from_range_10",

105 "from_range_100",

106 "from_range_degrees",

107 "from_range_int",

108 "is_ndarray_copy_enabled",

109 "set_ndarray_copy_enable",

110 "ndarray_copy_enable",

111 "ndarray_copy",

112 "closest_indexes",

113 "closest",

114 "interval",

115 "is_uniform",

116 "in_array",

117 "tstack",

118 "tsplit",

119 "row_as_diagonal",

120 "orient",

121 "centroid",

122 "fill_nan",

123 "has_only_nan",

124 "ndarray_write",

125 "zeros",

126 "ones",

127 "full",

128 "index_along_last_axis",

129 "format_array_as_row",

130]

131

132

133 class MixinDataclassFields:

134 """

135 Provide fields introspection for :class:`dataclass`-like classes.

136

137 This mixin extends dataclass functionality to enable introspection

138 capabilities, allowing programmatic access to field metadata and

139 properties.

140

141 Attributes

142 ----------

143 - :attr:`~colour.utilities.MixinDataclassFields.fields`

144 """

145

146 @property

147 def fields(self) -> tuple:

148 """

149 Getter for the fields of the :class:`dataclass`-like class.

150

151 Returns

152 -------

153 :class:`tuple`

154 :class:`dataclass`-like class fields.

155 """

156

157 return fields(self) # pyright: ignore

158

159

160 class MixinDataclassIterable(MixinDataclassFields):

161 """

162 Provide iteration capabilities over :class:`dataclass`-like classes.

163

164 This mixin extends dataclass functionality to enable dictionary-like

165 iteration over fields, allowing access to field names, values, and

166 name-value pairs through standard iteration protocols.

167

168 Attributes

169 ----------

170 - :attr:`~colour.utilities.MixinDataclassIterable.keys`

171 - :attr:`~colour.utilities.MixinDataclassIterable.values`

172 - :attr:`~colour.utilities.MixinDataclassIterable.items`

173

174 Methods

175 -------

176 - :meth:`~colour.utilities.MixinDataclassIterable.__iter__`

177

178 Notes

179 -----

180 - The :class:`colour.utilities.MixinDataclassIterable` class inherits

181 the methods from the following class:

182

183 - :class:`colour.utilities.MixinDataclassFields`

184 """

185

186 @property

187 def keys(self) -> tuple:

188 """

189 Getter for the :class:`dataclass`-like class keys, i.e., the field

190 names.

191

192 Returns

193 -------

194 :class:`tuple`

195 :class:`dataclass`-like class keys.

196 """

197

198 return tuple(field for field, _value in self)

199

200 @property

201 def values(self) -> tuple:

202 """

203 Getter for the :class:`dataclass`-like class field values.

204

205 Returns

206 -------

207 :class:`tuple`

208 :class:`dataclass`-like class field values.

209 """

210

211 return tuple(value for _field, value in self)

212

213 @property

214 def items(self) -> tuple:

215 """

216 Getter for the :class:`dataclass`-like class items, i.e., the field

217 names and values.

218

219 Returns

220 -------

221 :class:`tuple`

222 :class:`dataclass`-like class items.

223 """

224

225 return tuple((field, value) for field, value in self)

226

227 def __iter__(self) -> Generator:

228 """

229 Yield the :class:`dataclass`-like class fields.

230

231 Yields

232 ------

233 Generator

234 :class:`dataclass`-like class field generator.

235 """

236

237 yield from {

238 field.name: getattr(self, field.name) for field in self.fields

239 }.items()

240

241

242 class MixinDataclassArray(MixinDataclassIterable):

243 """

244 Provide conversion methods for :class:`dataclass`-like classes to

245 :class:`numpy.ndarray` objects.

246

247 This mixin extends dataclass functionality to enable seamless conversion

248 to NumPy arrays, facilitating numerical operations on structured data.

249

250 Methods

251 -------

252 - :meth:`~colour.utilities.MixinDataclassArray.__array__`

253

254 Notes

255 -----

256 - The :class:`colour.utilities.MixinDataclassArray` class

257 inherits the methods from the following classes:

258

259 - :class:`colour.utilities.MixinDataclassIterable`

260 - :class:`colour.utilities.MixinDataclassFields`

261 """

262

263 def __array__(

264 self, dtype: Type[DTypeReal] | None = None, copy: bool = True

265 ) -> NDArray:

266 """

267 Implement support for :class:`dataclass`-like class conversion to

268 :class:`numpy.ndarray` class.

269

270 A field set to *None* will be filled with `np.nan` according to the

271 shape of the first field not set with *None*.

272

273 Parameters

274 ----------

275 dtype

276 :class:`numpy.dtype` to use for conversion to `np.ndarray`,

277 default to the :class:`numpy.dtype` defined by

278 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

279 copy

280 Whether to return a copy of the underlying data, will always be

281 `True`, irrespective of the parameter value.

282

283 Returns

284 -------

285 :class:`numpy.ndarray`

286 :class:`dataclass`-like class converted to

287 :class:`numpy.ndarray`.

288 """

289

290 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

291

292 default = None

293 for _field, value in self:

294 if value is not None:

295 default = full(as_float_array(value).shape, np.nan)

296 break

297

298 return tstack(

299 cast(

300 "ArrayLike",

301 [value if value is not None else default for value in self.values],

302 ),

303 dtype=dtype,

304 )

305

306

307 class MixinDataclassArithmetic(MixinDataclassArray):

308 """

309 Provide mathematical operations for :class:`dataclass`-like classes.

310

311 This mixin extends dataclass functionality to enable arithmetic

312 operations, facilitating mathematical computations on dataclass instances

313 containing array-like data.

314

315 Methods

316 -------

317 - :meth:`~colour.utilities.MixinDataclassArray.__iadd__`

318 - :meth:`~colour.utilities.MixinDataclassArray.__add__`

319 - :meth:`~colour.utilities.MixinDataclassArray.__isub__`

320 - :meth:`~colour.utilities.MixinDataclassArray.__sub__`

321 - :meth:`~colour.utilities.MixinDataclassArray.__imul__`

322 - :meth:`~colour.utilities.MixinDataclassArray.__mul__`

323 - :meth:`~colour.utilities.MixinDataclassArray.__idiv__`

324 - :meth:`~colour.utilities.MixinDataclassArray.__div__`

325 - :meth:`~colour.utilities.MixinDataclassArray.__ipow__`

326 - :meth:`~colour.utilities.MixinDataclassArray.__pow__`

327 - :meth:`~colour.utilities.MixinDataclassArray.arithmetical_operation`

328

329 Notes

330 -----

331 - The :class:`colour.utilities.MixinDataclassArithmetic` class inherits

332 the methods from the following classes:

333

334 - :class:`colour.utilities.MixinDataclassArray`

335 - :class:`colour.utilities.MixinDataclassIterable`

336 - :class:`colour.utilities.MixinDataclassFields`

337 """

338

339 def __add__(self, a: Any) -> Self:

340 """

341 Implement support for addition.

342

343 Parameters

344 ----------

345 a

346 Variable :math:`a` to add.

347

348 Returns

349 -------

350 :class:`dataclass`

351 Variable added :class:`dataclass`-like class.

352 """

353

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

355

356 def __iadd__(self, a: Any) -> Self:

357 """

358 Implement support for in-place addition.

359

360 Parameters

361 ----------

362 a

363 Variable :math:`a` to add in-place.

364

365 Returns

366 -------

367 :class:`dataclass`

368 In-place variable added :class:`dataclass`-like class.

369 """

370

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

372

373 def __sub__(self, a: Any) -> Self:

374 """

375 Implement support for subtraction.

376

377 Parameters

378 ----------

379 a

380 Variable :math:`a` to subtract.

381

382 Returns

383 -------

384 :class:`dataclass`

385 Variable subtracted :class:`dataclass`-like class.

386 """

387

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

389

390 def __isub__(self, a: Any) -> Self:

391 """

392 Implement support for in-place subtraction.

393

394 Parameters

395 ----------

396 a

397 Variable :math:`a` to subtract in-place.

398

399 Returns

400 -------

401 :class:`dataclass`

402 In-place variable subtracted :class:`dataclass`-like class.

403 """

404

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

406

407 def __mul__(self, a: Any) -> Self:

408 """

409 Implement support for multiplication.

410

411 Parameters

412 ----------

413 a

414 Variable :math:`a` to multiply by.

415

416 Returns

417 -------

418 :class:`dataclass`

419 Variable multiplied :class:`dataclass`-like class.

420 """

421

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

423

424 def __imul__(self, a: Any) -> Self:

425 """

426 Implement support for in-place multiplication.

427

428 Parameters

429 ----------

430 a

431 Variable :math:`a` to multiply by in-place.

432

433 Returns

434 -------

435 :class:`dataclass`

436 In-place variable multiplied :class:`dataclass`-like class.

437 """

438

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

440

441 def __div__(self, a: Any) -> Self:

442 """

443 Implement support for division.

444

445 Parameters

446 ----------

447 a

448 Variable :math:`a` to divide by.

449

450 Returns

451 -------

452 :class:`dataclass`

453 Variable divided :class:`dataclass`-like class.

454 """

455

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

457

458 def __idiv__(self, a: Any) -> Self:

459 """

460 Implement support for in-place division.

461

462 Parameters

463 ----------

464 a

465 Variable :math:`a` to divide by in-place.

466

467 Returns

468 -------

469 :class:`dataclass`

470 In-place variable divided :class:`dataclass`-like class.

471 """

472

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

474

475 __itruediv__ = __idiv__

476 __truediv__ = __div__

477

478 def __pow__(self, a: Any) -> Self:

479 """

480 Implement support for exponentiation.

481

482 Parameters

483 ----------

484 a

485 Variable :math:`a` to exponentiate by.

486

487 Returns

488 -------

489 :class:`dataclass`

490 Variable exponentiated :class:`dataclass`-like class.

491 """

492

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

494

495 def __ipow__(self, a: Any) -> Self:

496 """

497 Implement support for in-place exponentiation.

498

499 Parameters

500 ----------

501 a

502 Variable :math:`a` to exponentiate by in-place.

503

504 Returns

505 -------

506 :class:`dataclass`

507 In-place variable exponentiated :class:`dataclass`-like

508 class.

509 """

510

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

512

513 def arithmetical_operation(

514 self, a: Any, operation: str, in_place: bool = False

515 ) -> Dataclass:

516 """

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

518 operand on the :class:`dataclass`-like class.

519

520 Parameters

521 ----------

522 a

523 Operand.

524 operation

525 Operation to perform.

526 in_place

527 Operation happens in place.

528

529 Returns

530 -------

531 :class:`dataclass`

532 :class:`dataclass`-like class with the arithmetical operation

533 performed.

534 """

535

536 callable_operation = {

537 "+": add,

538 "-": sub,

539 "*": mul,

540 "/": truediv,

541 "**": pow,

542 }[operation]

543

544 if is_dataclass(a):

545 a = as_float_array(a) # pyright: ignore

546

547 values = tsplit(callable_operation(as_float_array(self), a))

548 field_values = {field: values[i] for i, field in enumerate(self.keys)}

549 field_values.update({field: None for field, value in self if value is None})

550

551 dataclass = replace(self, **field_values) # pyright: ignore

552

553 if in_place:

554 for field in self.keys:

555 setattr(self, field, getattr(dataclass, field))

556

557 return self

558

559 return dataclass

560

561

562# NOTE : The following messages are pre-generated for performance reasons.

563 _ASSERTION_MESSAGE_DTYPE_INT = (

564 f'"dtype" must be one of the following types: "{DTypeInt.__args__}"'

565)

566

567 _ASSERTION_MESSAGE_DTYPE_FLOAT = (

568 f'"dtype" must be one of the following types: "{DTypeFloat.__args__}"'

569)

570

571 _ASSERTION_MESSAGE_DTYPE_COMPLEX = (

572 f'"dtype" must be one of the following types: "{DTypeComplex.__args__}"'

573)

574

575

576 def as_array(

577 a: ArrayLike | KeysView | ValuesView,

578 dtype: Type[DType] | None = None,

579) -> NDArray:

580 """

581 Convert the specified variable :math:`a` to :class:`numpy.ndarray` using

582 the specified :class:`numpy.dtype`.

583

584 Parameters

585 ----------

586 a

587 Variable :math:`a` to convert.

588 dtype

589 :class:`numpy.dtype` to use for conversion, default to the

590 :class:`numpy.dtype` defined by the

591 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

592

593 Returns

594 -------

595 :class:`numpy.ndarray`

596 Variable :math:`a` converted to :class:`numpy.ndarray`.

597

598 Examples

599 --------

600 >>> as_array([1, 2, 3]) # doctest: +ELLIPSIS

601 array([1, 2, 3]...)

602 >>> as_array([1, 2, 3], dtype=DTYPE_FLOAT_DEFAULT)

603 array([ 1., 2., 3.])

604 """

605

606 # TODO: Remove when https://github.com/numpy/numpy/issues/5718 is

607 # addressed.

608 if isinstance(a, (KeysView, ValuesView)):

609 a = list(a)

610

611 return np.asarray(a, dtype)

612

613

614@typing.overload

615 def as_int(a: float | DTypeFloat, dtype: Type[DTypeInt] | None = None) -> DTypeInt: ...

616@typing.overload

617 def as_int(

618 a: NDArray | Sequence[int], dtype: Type[DTypeInt] | None = None

619) -> NDArrayInt: ...

620@typing.overload

621 def as_int(

622 a: ArrayLike, dtype: Type[DTypeInt] | None = None

623) -> DTypeInt | NDArrayInt: ...

624 def as_int(a: ArrayLike, dtype: Type[DTypeInt] | None = None) -> DTypeInt | NDArrayInt:

625 """

626 Convert the specified variable :math:`a` to :class:`numpy.integer` using

627 the specified :class:`numpy.dtype`.

628

629 The function converts variable :math:`a` to an integer type. If variable

630 :math:`a` is not a scalar or 0-dimensional array, it is converted to

631 :class:`numpy.ndarray`.

632

633 Parameters

634 ----------

635 a

636 Variable :math:`a` to convert.

637 dtype

638 :class:`numpy.dtype` to use for conversion, default to the

639 :class:`numpy.dtype` defined by the

640 :attr:`colour.constant.DTYPE_INT_DEFAULT` attribute.

641

642 Returns

643 -------

644 :class:`numpy.ndarray`

645 Variable :math:`a` converted to :class:`numpy.integer`.

646

647 Examples

648 --------

649 >>> as_int(np.array(1))

650 1

651 >>> as_int(np.array([1])) # doctest: +SKIP

652 array([1])

653 >>> as_int(np.arange(10)) # doctest: +SKIP

654 array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]...)

655 """

656

657 dtype = optional(dtype, DTYPE_INT_DEFAULT)

658

659 attest(dtype in DTypeInt.__args__, _ASSERTION_MESSAGE_DTYPE_INT)

660

661 return dtype(a) # pyright: ignore

662

663

664@typing.overload

665 def as_float(

666 a: float | DTypeFloat, dtype: Type[DTypeFloat] | None = None

667) -> DTypeFloat: ...

668@typing.overload

669 def as_float(

670 a: NDArray | Sequence[float], dtype: Type[DTypeFloat] | None = None

671) -> NDArrayFloat: ...

672@typing.overload

673 def as_float(

674 a: ArrayLike, dtype: Type[DTypeFloat] | None = None

675) -> DTypeFloat | NDArrayFloat: ...

676 def as_float(

677 a: ArrayLike, dtype: Type[DTypeFloat] | None = None

678) -> DTypeFloat | NDArrayFloat:

679 """

680 Convert the specified variable :math:`a` to :class:`numpy.floating` using

681 the specified :class:`numpy.dtype`.

682

683 If variable :math:`a` is not a scalar or 0-dimensional, it is converted

684 to :class:`numpy.ndarray`.

685

686 Parameters

687 ----------

688 a

689 Variable :math:`a` to convert.

690 dtype

691 :class:`numpy.dtype` to use for conversion, default to the

692 :class:`numpy.dtype` defined by the

693 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

694

695 Returns

696 -------

697 :class:`numpy.ndarray`

698 Variable :math:`a` converted to :class:`numpy.floating`.

699

700 Examples

701 --------

702 >>> as_float(np.array(1))

703 1.0

704 >>> as_float(np.array([1]))

705 array([ 1.])

706 >>> as_float(np.arange(10))

707 array([ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9.])

708 """

709

710 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

711

712 attest(dtype in DTypeFloat.__args__, _ASSERTION_MESSAGE_DTYPE_FLOAT)

713

714 # NOTE: "np.float64" reduces dimensionality:

715 # >>> np.int64(np.array([[1]]))

716 # array([[1]])

717 # >>> np.float64(np.array([[1]]))

718 # 1.0

719 # See for more information https://github.com/numpy/numpy/issues/24283

720 if isinstance(a, np.ndarray) and a.size == 1 and a.ndim != 0:

721 return as_float_array(a, dtype)

722

723 return dtype(a) # pyright: ignore

724

725

726 def as_int_array(a: ArrayLike, dtype: Type[DTypeInt] | None = None) -> NDArrayInt:

727 """

728 Convert the specified variable :math:`a` to :class:`numpy.ndarray` using

729 the specified integer :class:`numpy.dtype`.

730

731 Parameters

732 ----------

733 a

734 Variable :math:`a` to convert.

735 dtype

736 :class:`numpy.dtype` to use for conversion, default to the

737 :class:`numpy.dtype` defined by the

738 :attr:`colour.constant.DTYPE_INT_DEFAULT` attribute.

739

740 Returns

741 -------

742 :class:`numpy.ndarray`

743 Variable :math:`a` converted to integer :class:`numpy.ndarray`.

744

745 Examples

746 --------

747 >>> as_int_array([1.0, 2.0, 3.0]) # doctest: +ELLIPSIS

748 array([1, 2, 3]...)

749 """

750

751 dtype = optional(dtype, DTYPE_INT_DEFAULT)

752

753 attest(dtype in DTypeInt.__args__, _ASSERTION_MESSAGE_DTYPE_INT)

754

755 return as_array(a, dtype)

756

757

758 def as_float_array(a: ArrayLike, dtype: Type[DTypeFloat] | None = None) -> NDArrayFloat:

759 """

760 Convert the specified variable :math:`a` to :class:`numpy.ndarray` using

761 the specified floating-point :class:`numpy.dtype`.

762

763 Parameters

764 ----------

765 a

766 Variable :math:`a` to convert.

767 dtype

768 Floating-point :class:`numpy.dtype` to use for conversion, default

769 to the :class:`numpy.dtype` defined by the

770 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

771

772 Returns

773 -------

774 :class:`numpy.ndarray`

775 Variable :math:`a` converted to floating-point

776 :class:`numpy.ndarray`.

777

778 Examples

779 --------

780 >>> as_float_array([1, 2, 3])

781 array([ 1., 2., 3.])

782 """

783

784 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

785

786 attest(dtype in DTypeFloat.__args__, _ASSERTION_MESSAGE_DTYPE_FLOAT)

787

788 return as_array(a, dtype)

789

790

791 def as_int_scalar(a: ArrayLike, dtype: Type[DTypeInt] | None = None) -> int:

792 """

793 Convert the specified variable :math:`a` to :class:`numpy.integer` using

794 the specified :class:`numpy.dtype`.

795

796 Parameters

797 ----------

798 a

799 Variable :math:`a` to convert.

800 dtype

801 :class:`numpy.dtype` to use for conversion, default to the

802 :class:`numpy.dtype` defined by the

803 :attr:`colour.constant.DTYPE_INT_DEFAULT` attribute.

804

805 Returns

806 -------

807 :class:`int`

808 Variable :math:`a` converted to :class:`numpy.integer`.

809

810 Warnings

811 --------

812 - The return type is effectively annotated as :class:`int` and not

813 :class:`numpy.integer`.

814

815 Examples

816 --------

817 >>> as_int_scalar(np.array(1))

818 1

819 """

820

821 a = np.squeeze(as_int_array(a, dtype))

822

823 attest(a.ndim == 0, f'"{a}" cannot be converted to "int" scalar!')

824

825 # TODO: Revisit when Numpy types are well established.

826 return cast("int", as_int(a, dtype))

827

828

829 def as_float_scalar(a: ArrayLike, dtype: Type[DTypeFloat] | None = None) -> float:

830 """

831 Convert the specified variable :math:`a` to :class:`numpy.floating` using

832 the specified :class:`numpy.dtype`.

833

834 Parameters

835 ----------

836 a

837 Variable :math:`a` to convert.

838 dtype

839 :class:`numpy.dtype` to use for conversion, default to the

840 :class:`numpy.dtype` defined by the

841 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

842

843 Returns

844 -------

845 :class:`float`

846 Variable :math:`a` converted to :class:`numpy.floating`.

847

848 Warnings

849 --------

850 - The return type is effectively annotated as :class:`float` and not

851 :class:`numpy.floating`.

852

853 Examples

854 --------

855 >>> as_float_scalar(np.array(1))

856 1.0

857 """

858

859 a = np.squeeze(as_float_array(a, dtype))

860

861 attest(a.ndim == 0, f'"{a}" cannot be converted to "float" scalar!')

862

863 # TODO: Revisit when Numpy types are well established.

864 return cast("float", as_float(a, dtype))

865

866

867 def as_complex_array(

868 a: ArrayLike,

869 dtype: Type[DTypeComplex] | None = None,

870) -> NDArrayComplex:

871 """

872 Convert the specified variable :math:`a` to :class:`numpy.ndarray` using

873 the specified complex :class:`numpy.dtype`.

874

875 Parameters

876 ----------

877 a

878 Variable :math:`a` to convert.

879 dtype

880 Complex :class:`numpy.dtype` to use for conversion, default

881 to the :class:`numpy.dtype` defined by the

882 :attr:`colour.constant.DTYPE_COMPLEX_DEFAULT` attribute.

883

884 Returns

885 -------

886 :class:`numpy.ndarray`

887 Variable :math:`a` converted to complex

888 :class:`numpy.ndarray`.

889

890 Examples

891 --------

892 >>> as_complex_array([1, 2, 3])

893 array([ 1.+0.j, 2.+0.j, 3.+0.j])

894 >>> as_complex_array([1 + 2j, 3 + 4j])

895 array([ 1.+2.j, 3.+4.j])

896 """

897

898 dtype = optional(dtype, DTYPE_COMPLEX_DEFAULT)

899

900 attest(dtype in DTypeComplex.__args__, _ASSERTION_MESSAGE_DTYPE_COMPLEX)

901

902 return as_array(a, dtype)

903

904

905 def set_default_int_dtype(

906 dtype: Type[DTypeInt] = DTYPE_INT_DEFAULT,

907) -> None:

908 """

909 Set the *Colour* default :class:`numpy.integer` precision by setting

910 :attr:`colour.constant.DTYPE_INT_DEFAULT` attribute with the specified

911 :class:`numpy.dtype` wherever the attribute is imported.

912

913 Parameters

914 ----------

915 dtype

916 :class:`numpy.dtype` to set

917 :attr:`colour.constant.DTYPE_INT_DEFAULT` with.

918

919 Notes

920 -----

921 - It is possible to define the integer precision at import time by

922 setting the *COLOUR_SCIENCE__DEFAULT_INT_DTYPE* environment

923 variable, for example `set COLOUR_SCIENCE__DEFAULT_INT_DTYPE=int32`.

924

925 Warnings

926 --------

927 This definition is mostly given for consistency purposes with

928 :func:`colour.utilities.set_default_float_dtype` definition but contrary

929 to the latter, changing *integer* precision will almost certainly

930 completely break *Colour*. With great power comes great responsibility.

931

932 Examples

933 --------

934 >>> as_int_array(np.ones(3)).dtype # doctest: +SKIP

935 dtype('int64')

936 >>> set_default_int_dtype(np.int32) # doctest: +SKIP

937 >>> as_int_array(np.ones(3)).dtype # doctest: +SKIP

938 dtype('int32')

939 >>> set_default_int_dtype(np.int64)

940 >>> as_int_array(np.ones(3)).dtype # doctest: +SKIP

941 dtype('int64')

942 """

943

944 # TODO: Investigate behaviour on Windows.

945 with suppress_warnings(colour_usage_warnings=True):

946 for module in sys.modules.values():

947 if not hasattr(module, "DTYPE_INT_DEFAULT"):

948 continue

949

950 module.DTYPE_INT_DEFAULT = dtype # pyright: ignore

951

952 CACHE_REGISTRY.clear_all_caches()

953

954

955 def set_default_float_dtype(

956 dtype: Type[DTypeFloat] = DTYPE_FLOAT_DEFAULT,

957) -> None:

958 """

959 Set the *Colour* default :class:`numpy.floating` precision by setting

960 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute with the

961 specified :class:`numpy.dtype` wherever the attribute is imported.

962

963 Parameters

964 ----------

965 dtype

966 :class:`numpy.dtype` to set

967 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` with.

968

969 Notes

970 -----

971 - It is possible to define the *float* precision at import time by

972 setting the *COLOUR_SCIENCE__DEFAULT_FLOAT_DTYPE* environment

973 variable, for example

974 `set COLOUR_SCIENCE__DEFAULT_FLOAT_DTYPE=float32`.

975 - Some definition returning a single-scalar ndarray might not

976 honour the specified *float* precision:

977 https://github.com/numpy/numpy/issues/16353

978

979 Warnings

980 --------

981 Changing *float* precision might result in various *Colour*

982 functionality breaking entirely:

983 https://github.com/numpy/numpy/issues/6860. With great power comes

984 great responsibility.

985

986 Examples

987 --------

988 >>> as_float_array(np.ones(3)).dtype

989 dtype('float64')

990 >>> set_default_float_dtype(np.float16) # doctest: +SKIP

991 >>> as_float_array(np.ones(3)).dtype # doctest: +SKIP

992 dtype('float16')

993 >>> set_default_float_dtype(np.float64)

994 >>> as_float_array(np.ones(3)).dtype

995 dtype('float64')

996 """

997

998 with suppress_warnings(colour_usage_warnings=True):

999 for module in sys.modules.values():

1000 if not hasattr(module, "DTYPE_FLOAT_DEFAULT"):

1001 continue

1002

1003 module.DTYPE_FLOAT_DEFAULT = dtype # pyright: ignore

1004

1005 CACHE_REGISTRY.clear_all_caches()

1006

1007

1008# TODO: Annotate with "Union[Literal['ignore', 'reference', '1', '100'], str]"

1009# when Python 3.7 is dropped.

1010 _DOMAIN_RANGE_SCALE = "reference"

1011"""

1012 Global variable storing the current *Colour* domain-range scale.

1013

1014 _DOMAIN_RANGE_SCALE

1015"""

1016

1017

1018 def get_domain_range_scale() -> Literal["ignore", "reference", "1", "100"] | str:

1019 """

1020 Return the current *Colour* domain-range scale.

1021

1022 The following scales are available:

1023

1024 - **'Reference'**, the default *Colour* domain-range scale which

1025 varies depending on the referenced algorithm, e.g., [0, 1],

1026 [0, 10], [0, 100], [0, 255], etc...

1027 - **'1'**, a domain-range scale normalised to [0, 1], it is

1028 important to acknowledge that this is a soft normalisation

1029 and it is possible to use negative out of gamut values or

1030 high dynamic range data exceeding 1.

1031

1032 Returns

1033 -------

1034 :class:`str`

1035 *Colour* domain-range scale.

1036

1037 Warnings

1038 --------

1039 - The **'Ignore'** and **'100'** domain-range scales are for

1040 internal usage only!

1041 """

1042

1043 return _DOMAIN_RANGE_SCALE

1044

1045

1046 def set_domain_range_scale(

1047 scale: (

1048 Literal["ignore", "reference", "Ignore", "Reference", "1", "100"] | str

1049 ) = "reference",

1050) -> None:

1051 """

1052 Set the current *Colour* domain-range scale.

1053

1054 The following scales are available:

1055

1056 - **'Reference'**, the default *Colour* domain-range scale which

1057 varies depending on the referenced algorithm, e.g., [0, 1],

1058 [0, 10], [0, 100], [0, 255], etc...

1059 - **'1'**, a domain-range scale normalised to [0, 1], it is

1060 important to acknowledge that this is a soft normalisation and it

1061 is possible to use negative out of gamut values or high dynamic

1062 range data exceeding 1.

1063

1064 Parameters

1065 ----------

1066 scale

1067 *Colour* domain-range scale to set.

1068

1069 Warnings

1070 --------

1071 - The **'Ignore'** and **'100'** domain-range scales are for

1072 internal usage only!

1073 """

1074

1075 global _DOMAIN_RANGE_SCALE # noqa: PLW0603

1076

1077 _DOMAIN_RANGE_SCALE = validate_method(

1078 str(scale),

1079 ("ignore", "reference", "1", "100"),

1080 '"{0}" scale is invalid, it must be one of {1}!',

1081 )

1082

1083

1084 class domain_range_scale:

1085 """

1086 Define a context manager and decorator to temporarily set the *Colour*

1087 domain-range scale.

1088

1089 The following scales are available:

1090

1091 - **'Reference'**, the default *Colour* domain-range scale which

1092 varies depending on the referenced algorithm, e.g., [0, 1],

1093 [0, 10], [0, 100], [0, 255], etc...

1094 - **'1'**, a domain-range scale normalised to [0, 1], it is

1095 important to acknowledge that this is a soft normalisation and it

1096 is possible to use negative out of gamut values or high dynamic

1097 range data exceeding 1.

1098

1099 Parameters

1100 ----------

1101 scale

1102 *Colour* domain-range scale to set.

1103

1104 Warnings

1105 --------

1106 - The **'Ignore'** and **'100'** domain-range scales are for

1107 internal usage only!

1108

1109 Examples

1110 --------

1111 With *Colour* domain-range scale set to **'Reference'**:

1112

1113 >>> with domain_range_scale("1"):

1114 ... to_domain_1(1)

1115 array(1.0)

1116 >>> with domain_range_scale("Reference"):

1117 ... from_range_1(1)

1118 array(1.0)

1119

1120 With *Colour* domain-range scale set to **'1'**:

1121

1122 >>> with domain_range_scale("1"):

1123 ... to_domain_1(1)

1124 array(1.0)

1125 >>> with domain_range_scale("1"):

1126 ... from_range_1(1)

1127 array(1.0)

1128

1129 With *Colour* domain-range scale set to **'100'** (unsupported):

1130

1131 >>> with domain_range_scale("100"):

1132 ... to_domain_1(1)

1133 array(0.01)

1134 >>> with domain_range_scale("100"):

1135 ... from_range_1(1)

1136 array(100.0)

1137 """

1138

1139 def __init__(

1140 self,

1141 scale: (

1142 Literal["ignore", "reference", "Ignore", "Reference", "1", "100"] | str

1143 ),

1144 ) -> None:

1145 self._scale = scale

1146 self._previous_scale = get_domain_range_scale()

1147

1148 def __enter__(self) -> Self:

1149 """Set the new domain-range scale upon entering the context manager."""

1150

1151 set_domain_range_scale(self._scale)

1152

1153 return self

1154

1155 def __exit__(self, *args: Any) -> None:

1156 """

1157 Restore the previous domain-range scale upon exiting the context

1158 manager.

1159 """

1160

1161 set_domain_range_scale(self._previous_scale)

1162

1163 def __call__(self, function: Callable) -> Any:

1164 """

1165 Call the wrapped definition with domain-range scale management.

1166 """

1167

1168 @functools.wraps(function)

1169 def wrapper(*args: Any, **kwargs: Any) -> Any:

1170 with self:

1171 return function(*args, **kwargs)

1172

1173 return wrapper

1174

1175

1176 _CACHE_DOMAIN_RANGE_SCALE_METADATA: dict = CACHE_REGISTRY.register_cache(

1177 f"{__name__}._CACHE_DOMAIN_RANGE_SCALE_METADATA"

1178)

1179

1180

1181 def get_domain_range_scale_metadata(function: Callable) -> dict[str, Any]:

1182 """

1183 Extract domain-range scale metadata from function type hints.

1184

1185 Extracts scale factors from PEP 593 ``Annotated`` type hints on function

1186 parameters and return values. This metadata indicates which scale factors

1187 to use when converting between 'Reference' and '1' modes.

1188

1189 Parameters

1190 ----------

1191 function

1192 Function to extract metadata from.

1193

1194 Returns

1195 -------

1196 :class:`dict`

1197 Dictionary with keys:

1198

1199 - ``domain``: Dict mapping parameter names to their scale factors

1200 - ``range``: Scale factor for return value (int, tuple, or None)

1201

1202 Examples

1203 --------

1204 >>> from colour.hints import Annotated, ArrayLike, NDArrayFloat

1205 >>> def example_function(

1206 ... XYZ: Domain1,

1207 ... illuminant: ArrayLike = None,

1208 ... ) -> Range100:

1209 ... pass

1210 >>> metadata = get_domain_range_scale_metadata(example_function)

1211 >>> metadata["domain"]

1212 {'XYZ': 1}

1213 >>> metadata["range"]

1214 100

1215 """

1216

1217 # Unwrap functools.partial to get the underlying function

1218 if hasattr(function, "func"):

1219 function = function.func # pyright: ignore

1220

1221 cache_key = id(function)

1222

1223 if is_caching_enabled() and cache_key in _CACHE_DOMAIN_RANGE_SCALE_METADATA:

1224 return _CACHE_DOMAIN_RANGE_SCALE_METADATA[cache_key]

1225

1226 metadata: dict[str, Any] = {"domain": {}, "range": None}

1227

1228 def extract_scale_from_hint(hint: Any) -> Any | None:

1229 """

1230 Extract scale metadata from a type hint, handling Union types.

1231

1232 Parameters

1233 ----------

1234 hint

1235 Type hint to extract scale from.

1236

1237 Returns

1238 -------

1239 :class:`int` | :class:`tuple` | :class:`None`

1240 Scale metadata if found, None otherwise.

1241 """

1242

1243 # Direct Annotated type with __metadata__

1244 if hasattr(hint, "__metadata__") and hint.__metadata__:

1245 return next(iter(hint.__metadata__))

1246

1247 # Union type: check if any arg is Annotated

1248 origin = get_origin(hint)

1249 if origin is Union:

1250 for arg in get_args(hint):

1251 if hasattr(arg, "__metadata__") and arg.__metadata__:

1252 return next(iter(arg.__metadata__))

1253

1254 return None

1255

1256 try:

1257 hints = get_type_hints(function, include_extras=True)

1258 # Process hints from get_type_hints (actual types with __metadata__)

1259 for parameter_name, hint in hints.items():

1260 scale = extract_scale_from_hint(hint)

1261 if scale is not None:

1262 if parameter_name == "return":

1263 metadata["range"] = scale

1264 else:

1265 metadata["domain"][parameter_name] = scale

1266 except (AttributeError, TypeError, NameError):

1267 # Fallback: parse string annotations (when `from __future__ import annotations`)

1268 # Mapping of type alias names to their scale values

1269 type_alias_scales = {

1270 "Domain1": 1,

1271 "Domain10": 10,

1272 "Domain100": 100,

1273 "Domain360": 360,

1274 "Domain100_100_360": (100, 100, 360),

1275 "Range1": 1,

1276 "Range10": 10,

1277 "Range100": 100,

1278 "Range360": 360,

1279 "Range100_100_360": (100, 100, 360),

1280 }

1281

1282 hints = getattr(function, "__annotations__", {})

1283 for parameter_name, hint in hints.items():

1284 scale = None

1285

1286 # Check if hint is a type alias name

1287 if isinstance(hint, str) and hint in type_alias_scales:

1288 scale = type_alias_scales[hint]

1289 # Extract scale from string: "Annotated[Type, scale]" -> scale

1290 elif (

1291 isinstance(hint, str)

1292 and "Annotated[" in hint

1293 and (match := re.search(r"Annotated\[[^,]+,\s*([^\]]+)\]", hint))

1294 ):

1295 scale_string = match.group(1).strip()

1296 # Evaluate scale (could be int, tuple, etc.)

1297 try:

1298 scale = eval(scale_string) # noqa: S307

1299 except (SyntaxError, NameError, ValueError):

1300 scale = scale_string

1301

1302 if scale is not None:

1303 if parameter_name == "return":

1304 metadata["range"] = scale

1305 else:

1306 metadata["domain"][parameter_name] = scale

1307

1308 if is_caching_enabled():

1309 _CACHE_DOMAIN_RANGE_SCALE_METADATA[cache_key] = metadata

1310

1311 return metadata

1312

1313

1314 def to_domain_1(

1315 a: ArrayLike,

1316 scale_factor: ArrayLike = 100,

1317 dtype: Type[DTypeFloat] | None = None,

1318) -> NDArray:

1319 """

1320 Scale the specified array :math:`a` to domain **'1'**.

1321

1322 The behaviour is as follows:

1323

1324 - If *Colour* domain-range scale is **'Reference'** or **'1'**, the

1325 definition is almost entirely by-passed and will conveniently

1326 convert array :math:`a` to :class:`np.ndarray`.

1327 - If *Colour* domain-range scale is **'100'** (currently unsupported

1328 private value only used for unit tests), array :math:`a` is divided

1329 by ``scale_factor``, typically 100.

1330

1331 Parameters

1332 ----------

1333 a

1334 Array :math:`a` to scale to domain **'1'**.

1335 scale_factor

1336 Scale factor, usually *numeric* but can be a :class:`numpy.ndarray`

1337 if some axes need different scaling to be brought to domain **'1'**.

1338 dtype

1339 Data type used for the conversion to :class:`np.ndarray`.

1340

1341 Returns

1342 -------

1343 :class:`numpy.ndarray`

1344 Array :math:`a` scaled to domain **'1'**.

1345

1346 Examples

1347 --------

1348 With *Colour* domain-range scale set to **'Reference'**:

1349

1350 >>> with domain_range_scale("Reference"):

1351 ... to_domain_1(1)

1352 array(1.0)

1353

1354 With *Colour* domain-range scale set to **'1'**:

1355

1356 >>> with domain_range_scale("1"):

1357 ... to_domain_1(1)

1358 array(1.0)

1359

1360 With *Colour* domain-range scale set to **'100'** (unsupported):

1361

1362 >>> with domain_range_scale("100"):

1363 ... to_domain_1(1)

1364 array(0.01)

1365 """

1366

1367 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1368

1369 a = as_float_array(a, dtype).copy()

1370

1371 if _DOMAIN_RANGE_SCALE == "100":

1372 a /= as_float_array(scale_factor)

1373

1374 return a

1375

1376

1377 def to_domain_10(

1378 a: ArrayLike,

1379 scale_factor: ArrayLike = 10,

1380 dtype: Type[DTypeFloat] | None = None,

1381) -> NDArray:

1382 """

1383 Scale the specified array :math:`a` to domain **'10'**, used by the

1384 *Munsell Renotation System*.

1385

1386 The behaviour is as follows:

1387

1388 - If *Colour* domain-range scale is **'Reference'**, the definition

1389 is almost entirely by-passed and will conveniently convert array

1390 :math:`a` to :class:`np.ndarray`.

1391 - If *Colour* domain-range scale is **'1'**, array :math:`a` is

1392 multiplied by ``scale_factor``, typically 10.

1393 - If *Colour* domain-range scale is **'100'** (currently unsupported

1394 private value only used for unit tests), array :math:`a` is

1395 divided by ``scale_factor``, typically 10.

1396

1397 Parameters

1398 ----------

1399 a

1400 Array :math:`a` to scale to domain **'10'**.

1401 scale_factor

1402 Scale factor, usually *numeric* but can be a :class:`numpy.ndarray`

1403 if some axes need different scaling to be brought to domain

1404 **'10'**.

1405 dtype

1406 Data type used for the conversion to :class:`np.ndarray`.

1407

1408 Returns

1409 -------

1410 :class:`numpy.ndarray`

1411 Array :math:`a` scaled to domain **'10'**.

1412

1413 Examples

1414 --------

1415 With *Colour* domain-range scale set to **'Reference'**:

1416

1417 >>> with domain_range_scale("Reference"):

1418 ... to_domain_10(1)

1419 array(1.0)

1420

1421 With *Colour* domain-range scale set to **'1'**:

1422

1423 >>> with domain_range_scale("1"):

1424 ... to_domain_10(1)

1425 array(10.0)

1426

1427 With *Colour* domain-range scale set to **'100'** (unsupported):

1428

1429 >>> with domain_range_scale("100"):

1430 ... to_domain_10(1)

1431 array(0.1)

1432 """

1433

1434 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1435

1436 a = as_float_array(a, dtype).copy()

1437

1438 if _DOMAIN_RANGE_SCALE == "1":

1439 a *= as_float_array(scale_factor)

1440

1441 if _DOMAIN_RANGE_SCALE == "100":

1442 a /= as_float_array(scale_factor)

1443

1444 return a

1445

1446

1447 def to_domain_100(

1448 a: ArrayLike,

1449 scale_factor: ArrayLike = 100,

1450 dtype: Type[DTypeFloat] | None = None,

1451) -> NDArray:

1452 """

1453 Scale the specified array :math:`a` to domain **'100'**.

1454

1455 The behaviour is as follows:

1456

1457 - If *Colour* domain-range scale is **'Reference'** or **'100'**

1458 (currently unsupported private value only used for unit tests), the

1459 definition is almost entirely by-passed and will conveniently

1460 convert array :math:`a` to :class:`np.ndarray`.

1461 - If *Colour* domain-range scale is **'1'**, array :math:`a` is

1462 multiplied by ``scale_factor``, typically 100.

1463

1464 Parameters

1465 ----------

1466 a

1467 Array :math:`a` to scale to domain **'100'**.

1468 scale_factor

1469 Scale factor, usually *numeric* but can be a :class:`numpy.ndarray`

1470 if some axes need different scaling to be brought to domain

1471 **'100'**.

1472 dtype

1473 Data type used for the conversion to :class:`np.ndarray`.

1474

1475 Returns

1476 -------

1477 :class:`numpy.ndarray`

1478 Array :math:`a` scaled to domain **'100'**.

1479

1480 Examples

1481 --------

1482 With *Colour* domain-range scale set to **'Reference'**:

1483

1484 >>> with domain_range_scale("Reference"):

1485 ... to_domain_100(1)

1486 array(1.0)

1487

1488 With *Colour* domain-range scale set to **'1'**:

1489

1490 >>> with domain_range_scale("1"):

1491 ... to_domain_100(1)

1492 array(100.0)

1493

1494 With *Colour* domain-range scale set to **'100'** (unsupported):

1495

1496 >>> with domain_range_scale("100"):

1497 ... to_domain_100(1)

1498 array(1.0)

1499 """

1500

1501 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1502

1503 a = as_float_array(a, dtype).copy()

1504

1505 if _DOMAIN_RANGE_SCALE == "1":

1506 a *= as_float_array(scale_factor)

1507

1508 return a

1509

1510

1511 def to_domain_degrees(

1512 a: ArrayLike,

1513 scale_factor: ArrayLike = 360,

1514 dtype: Type[DTypeFloat] | None = None,

1515) -> NDArray:

1516 """

1517 Scale the specified array :math:`a` to degrees domain.

1518

1519 The behaviour is as follows:

1520

1521 - If *Colour* domain-range scale is **'Reference'**, the definition

1522 is almost entirely by-passed and will conveniently convert array

1523 :math:`a` to :class:`np.ndarray`.

1524 - If *Colour* domain-range scale is **'1'**, array :math:`a` is

1525 multiplied by ``scale_factor``, typically 360.

1526 - If *Colour* domain-range scale is **'100'** (currently unsupported

1527 private value only used for unit tests), array :math:`a` is

1528 multiplied by ``scale_factor`` / 100, typically 360 / 100.

1529

1530 Parameters

1531 ----------

1532 a

1533 Array :math:`a` to scale to degrees domain.

1534 scale_factor

1535 Scale factor, usually *numeric* but can be a :class:`numpy.ndarray`

1536 if some axes need different scaling to be brought to degrees domain.

1537 dtype

1538 Data type used for the conversion to :class:`np.ndarray`.

1539

1540 Returns

1541 -------

1542 :class:`numpy.ndarray`

1543 Array :math:`a` scaled to degrees domain.

1544

1545 Examples

1546 --------

1547 With *Colour* domain-range scale set to **'Reference'**:

1548

1549 >>> with domain_range_scale("Reference"):

1550 ... to_domain_degrees(1)

1551 array(1.0)

1552

1553 With *Colour* domain-range scale set to **'1'**:

1554

1555 >>> with domain_range_scale("1"):

1556 ... to_domain_degrees(1)

1557 array(360.0)

1558

1559 With *Colour* domain-range scale set to **'100'** (unsupported):

1560

1561 >>> with domain_range_scale("100"):

1562 ... to_domain_degrees(1)

1563 array(3.6)

1564 """

1565

1566 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1567

1568 a = as_float_array(a, dtype).copy()

1569

1570 if _DOMAIN_RANGE_SCALE == "1":

1571 a *= as_float_array(scale_factor)

1572

1573 if _DOMAIN_RANGE_SCALE == "100":

1574 a *= as_float_array(scale_factor) / 100

1575

1576 return a

1577

1578

1579 def to_domain_int(

1580 a: ArrayLike,

1581 bit_depth: ArrayLike = 8,

1582 dtype: Type[DTypeFloat] | None = None,

1583) -> NDArray:

1584 """

1585 Scale the specified array :math:`a` to integer domain.

1586

1587 The behaviour is as follows:

1588

1589 - If *Colour* domain-range scale is **'Reference'**, the definition

1590 is almost entirely by-passed and will conveniently convert array

1591 :math:`a` to :class:`np.ndarray`.

1592 - If *Colour* domain-range scale is **'1'**, array :math:`a` is

1593 multiplied by :math:`2^{bit\\_depth} - 1`.

1594 - If *Colour* domain-range scale is **'100'** (currently unsupported

1595 private value only used for unit tests), array :math:`a` is

1596 multiplied by :math:`2^{bit\\_depth} - 1`.

1597

1598 Parameters

1599 ----------

1600 a

1601 Array :math:`a` to scale to integer domain.

1602 bit_depth

1603 Bit-depth, usually *int* but can be a :class:`numpy.ndarray` if

1604 some axis need different scaling to be brought to integer domain.

1605 dtype

1606 Data type used for the conversion to :class:`np.ndarray`.

1607

1608 Returns

1609 -------

1610 :class:`numpy.ndarray`

1611 Array :math:`a` scaled to integer domain.

1612

1613 Notes

1614 -----

1615 - To avoid precision issues and rounding, the scaling is performed

1616 on *float* numbers.

1617

1618 Examples

1619 --------

1620 With *Colour* domain-range scale set to **'Reference'**:

1621

1622 >>> with domain_range_scale("Reference"):

1623 ... to_domain_int(1)

1624 array(1.0)

1625

1626 With *Colour* domain-range scale set to **'1'**:

1627

1628 >>> with domain_range_scale("1"):

1629 ... to_domain_int(1)

1630 array(255.0)

1631

1632 With *Colour* domain-range scale set to **'100'** (unsupported):

1633

1634 >>> with domain_range_scale("100"):

1635 ... to_domain_int(1)

1636 array(2.55)

1637 """

1638

1639 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1640

1641 a = as_float_array(a, dtype).copy()

1642

1643 maximum_code_value: NDArray[DTypeInt] = np.power(2, bit_depth) - 1

1644 if _DOMAIN_RANGE_SCALE == "1":

1645 a *= maximum_code_value

1646

1647 if _DOMAIN_RANGE_SCALE == "100":

1648 a *= maximum_code_value / 100

1649

1650 return a

1651

1652

1653 def from_range_1(

1654 a: ArrayLike,

1655 scale_factor: ArrayLike = 100,

1656 dtype: Type[DTypeFloat] | None = None,

1657) -> NDArray:

1658 """

1659 Scale the specified array :math:`a` from range **'1'**.

1660

1661 The behaviour is as follows:

1662

1663 - If *Colour* domain-range scale is **'Reference'** or **'1'**, the

1664 definition is entirely by-passed.

1665 - If *Colour* domain-range scale is **'100'** (currently unsupported

1666 private value only used for unit tests), array :math:`a` is

1667 multiplied by ``scale_factor``, typically 100.

1668

1669 Parameters

1670 ----------

1671 a

1672 Array :math:`a` to scale from range **'1'**.

1673 scale_factor

1674 Scale factor, usually *numeric* but can be a :class:`numpy.ndarray`

1675 if some axis need different scaling to be brought from range

1676 **'1'**.

1677 dtype

1678 Data type used for the conversion to :class:`np.ndarray`.

1679

1680 Returns

1681 -------

1682 :class:`numpy.ndarray`

1683 Array :math:`a` scaled from range **'1'**.

1684

1685 Warnings

1686 --------

1687 The scale conversion of variable :math:`a` happens in-place, i.e.,

1688 :math:`a` will be mutated!

1689

1690 Examples

1691 --------

1692 With *Colour* domain-range scale set to **'Reference'**:

1693

1694 >>> with domain_range_scale("Reference"):

1695 ... from_range_1(1)

1696 array(1.0)

1697

1698 With *Colour* domain-range scale set to **'1'**:

1699

1700 >>> with domain_range_scale("1"):

1701 ... from_range_1(1)

1702 array(1.0)

1703

1704 With *Colour* domain-range scale set to **'100'** (unsupported):

1705

1706 >>> with domain_range_scale("100"):

1707 ... from_range_1(1)

1708 array(100.0)

1709 """

1710

1711 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1712

1713 a = as_float_array(a, dtype)

1714

1715 if _DOMAIN_RANGE_SCALE == "100":

1716 a *= as_float_array(scale_factor)

1717

1718 return a

1719

1720

1721 def from_range_10(

1722 a: ArrayLike,

1723 scale_factor: ArrayLike = 10,

1724 dtype: Type[DTypeFloat] | None = None,

1725) -> NDArray:

1726 """

1727 Scale the specified array :math:`a` from range **'10'**, used by the

1728 *Munsell Renotation System*.

1729

1730 The behaviour is as follows:

1731

1732 - If *Colour* domain-range scale is **'Reference'**, the definition

1733 is entirely by-passed.

1734 - If *Colour* domain-range scale is **'1'**, array :math:`a` is

1735 divided by ``scale_factor``, typically 10.

1736 - If *Colour* domain-range scale is **'100'** (currently unsupported

1737 private value only used for unit tests), array :math:`a` is

1738 multiplied by ``scale_factor``, typically 10.

1739

1740 Parameters

1741 ----------

1742 a

1743 Array :math:`a` to scale from range **'10'**.

1744 scale_factor

1745 Scale factor, usually *numeric* but can be a

1746 :class:`numpy.ndarray` if some axis need different scaling to be

1747 brought from range **'10'**.

1748 dtype

1749 Data type used for the conversion to :class:`np.ndarray`.

1750

1751 Returns

1752 -------

1753 :class:`numpy.ndarray`

1754 Array :math:`a` scaled from range **'10'**.

1755

1756 Warnings

1757 --------

1758 The scale conversion of variable :math:`a` happens in-place, i.e.,

1759 :math:`a` will be mutated!

1760

1761 Examples

1762 --------

1763 With *Colour* domain-range scale set to **'Reference'**:

1764

1765 >>> with domain_range_scale("Reference"):

1766 ... from_range_10(1)

1767 array(1.0)

1768

1769 With *Colour* domain-range scale set to **'1'**:

1770

1771 >>> with domain_range_scale("1"):

1772 ... from_range_10(1)

1773 array(0.1)

1774

1775 With *Colour* domain-range scale set to **'100'** (unsupported):

1776

1777 >>> with domain_range_scale("100"):

1778 ... from_range_10(1)

1779 array(10.0)

1780 """

1781

1782 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1783

1784 a = as_float_array(a, dtype)

1785

1786 if _DOMAIN_RANGE_SCALE == "1":

1787 a /= as_float_array(scale_factor)

1788

1789 if _DOMAIN_RANGE_SCALE == "100":

1790 a *= as_float_array(scale_factor)

1791

1792 return a

1793

1794

1795 def from_range_100(

1796 a: ArrayLike,

1797 scale_factor: ArrayLike = 100,

1798 dtype: Type[DTypeFloat] | None = None,

1799) -> NDArray:

1800 """

1801 Scale the specified array :math:`a` from range **'100'**.

1802

1803 The behaviour is as follows:

1804

1805 - If *Colour* domain-range scale is **'Reference'** or **'100'**

1806 (currently unsupported private value only used for unit tests), the

1807 definition is entirely by-passed.

1808 - If *Colour* domain-range scale is **'1'**, array :math:`a` is

1809 divided by ``scale_factor``, typically 100.

1810

1811 Parameters

1812 ----------

1813 a

1814 Array :math:`a` to scale from range **'100'**.

1815 scale_factor

1816 Scale factor, usually *numeric* but can be a :class:`numpy.ndarray`

1817 if some axes require different scaling to be brought from range

1818 **'100'**.

1819 dtype

1820 Data type used for the conversion to :class:`numpy.ndarray`.

1821

1822 Returns

1823 -------

1824 :class:`numpy.ndarray`

1825 Array :math:`a` scaled from range **'100'**.

1826

1827 Warnings

1828 --------

1829 The scale conversion of variable :math:`a` happens in-place, i.e.,

1830 :math:`a` will be mutated!

1831

1832 Examples

1833 --------

1834 With *Colour* domain-range scale set to **'Reference'**:

1835

1836 >>> with domain_range_scale("Reference"):

1837 ... from_range_100(1)

1838 array(1.0)

1839

1840 With *Colour* domain-range scale set to **'1'**:

1841

1842 >>> with domain_range_scale("1"):

1843 ... from_range_100(1)

1844 array(0.01)

1845

1846 With *Colour* domain-range scale set to **'100'** (unsupported):

1847

1848 >>> with domain_range_scale("100"):

1849 ... from_range_100(1)

1850 array(1.0)

1851 """

1852

1853 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1854

1855 a = as_float_array(a, dtype)

1856

1857 if _DOMAIN_RANGE_SCALE == "1":

1858 a /= as_float_array(scale_factor)

1859

1860 return a

1861

1862

1863 def from_range_degrees(

1864 a: ArrayLike,

1865 scale_factor: ArrayLike = 360,

1866 dtype: Type[DTypeFloat] | None = None,

1867) -> NDArray:

1868 """

1869 Scale the specified array :math:`a` from degrees range.

1870

1871 The behaviour is as follows:

1872

1873 - If *Colour* domain-range scale is **'Reference'**, the definition

1874 is entirely by-passed.

1875 - If *Colour* domain-range scale is **'1'**, array :math:`a` is

1876 divided by ``scale_factor``, typically 360.

1877 - If *Colour* domain-range scale is **'100'** (currently unsupported

1878 private value only used for unit tests), array :math:`a` is

1879 divided by ``scale_factor`` / 100, typically 360 / 100.

1880

1881 Parameters

1882 ----------

1883 a

1884 Array :math:`a` to scale from degrees range.

1885 scale_factor

1886 Scale factor, usually *numeric* but can be a

1887 :class:`numpy.ndarray` if some axes need different scaling to be

1888 brought from degrees range.

1889 dtype

1890 Data type used for the conversion to :class:`numpy.ndarray`.

1891

1892 Returns

1893 -------

1894 :class:`numpy.ndarray`

1895 Array :math:`a` scaled from degrees range.

1896

1897 Warnings

1898 --------

1899 The scale conversion of variable :math:`a` happens in-place, i.e.,

1900 :math:`a` will be mutated!

1901

1902 Examples

1903 --------

1904 With *Colour* domain-range scale set to **'Reference'**:

1905

1906 >>> with domain_range_scale("Reference"):

1907 ... from_range_degrees(1)

1908 array(1.0)

1909

1910 With *Colour* domain-range scale set to **'1'**:

1911

1912 >>> with domain_range_scale("1"):

1913 ... from_range_degrees(1) # doctest: +ELLIPSIS

1914 array(0.0027777...)

1915

1916 With *Colour* domain-range scale set to **'100'** (unsupported):

1917

1918 >>> with domain_range_scale("100"):

1919 ... from_range_degrees(1) # doctest: +ELLIPSIS

1920 array(0.2777777...)

1921 """

1922

1923 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1924

1925 a = as_float_array(a, dtype)

1926

1927 if _DOMAIN_RANGE_SCALE == "1":

1928 a /= as_float_array(scale_factor)

1929

1930 if _DOMAIN_RANGE_SCALE == "100":

1931 a /= as_float_array(scale_factor) / 100

1932

1933 return a

1934

1935

1936 def from_range_int(

1937 a: ArrayLike,

1938 bit_depth: ArrayLike = 8,

1939 dtype: Type[DTypeFloat] | None = None,

1940) -> NDArray:

1941 """

1942 Scale the specified array :math:`a` from integer range.

1943

1944 The behaviour is as follows:

1945

1946 - If *Colour* domain-range scale is **'Reference'**, the definition

1947 is entirely by-passed.

1948 - If *Colour* domain-range scale is **'1'**, array :math:`a` is

1949 converted to :class:`np.ndarray` and divided by

1950 :math:`2^{bit\\_depth} - 1`.

1951 - If *Colour* domain-range scale is **'100'** (currently unsupported

1952 private value only used for unit tests), array :math:`a` is

1953 converted to :class:`np.ndarray` and divided by

1954 :math:`2^{bit\\_depth} - 1`.

1955

1956 Parameters

1957 ----------

1958 a

1959 Array :math:`a` to scale from integer range.

1960 bit_depth

1961 Bit-depth, usually *int* but can be a :class:`numpy.ndarray` if

1962 some axes need different scaling to be brought from integer range.

1963 dtype

1964 Data type used for the conversion to :class:`np.ndarray`.

1965

1966 Returns

1967 -------

1968 :class:`numpy.ndarray`

1969 Array :math:`a` scaled from integer range.

1970

1971 Warnings

1972 --------

1973 The scale conversion of variable :math:`a` happens in-place, i.e.,

1974 :math:`a` will be mutated!

1975

1976 Notes

1977 -----

1978 - To avoid precision issues and rounding, the scaling is performed on

1979 *float* numbers.

1980

1981 Examples

1982 --------

1983 With *Colour* domain-range scale set to **'Reference'**:

1984

1985 >>> with domain_range_scale("Reference"):

1986 ... from_range_int(1)

1987 array(1.0)

1988

1989 With *Colour* domain-range scale set to **'1'**:

1990

1991 >>> with domain_range_scale("1"):

1992 ... from_range_int(1) # doctest: +ELLIPSIS

1993 array(0.0039215...)

1994

1995 With *Colour* domain-range scale set to **'100'** (unsupported):

1996

1997 >>> with domain_range_scale("100"):

1998 ... from_range_int(1) # doctest: +ELLIPSIS

1999 array(0.3921568...)

2000 """

2001

2002 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

2003

2004 a = as_float_array(a, dtype)

2005

2006 maximum_code_value: NDArray[DTypeInt] = np.power(2, bit_depth) - 1

2007 if _DOMAIN_RANGE_SCALE == "1":

2008 a /= maximum_code_value

2009

2010 if _DOMAIN_RANGE_SCALE == "100":

2011 a /= maximum_code_value / 100

2012

2013 return a

2014

2015

2016 _NDARRAY_COPY_ENABLED: bool = True

2017"""

2018 Global variable storing the current *Colour* state for

2019:class:`numpy.ndarray` copy.

2020"""

2021

2022

2023 def is_ndarray_copy_enabled() -> bool:

2024 """

2025 Determine whether *Colour* :class:`numpy.ndarray` copy is enabled.

2026

2027 Various API objects return a copy of their internal

2028 :class:`numpy.ndarray` for safety purposes, but this can be a slow

2029 operation impacting performance.

2030

2031 Returns

2032 -------

2033 :class:`bool`

2034 Whether *Colour* :class:`numpy.ndarray` copy is enabled.

2035

2036 Examples

2037 --------

2038 >>> with ndarray_copy_enable(False):

2039 ... is_ndarray_copy_enabled()

2040 False

2041 >>> with ndarray_copy_enable(True):

2042 ... is_ndarray_copy_enabled()

2043 True

2044 """

2045

2046 return _NDARRAY_COPY_ENABLED

2047

2048

2049 def set_ndarray_copy_enable(enable: bool) -> None:

2050 """

2051 Set the *Colour* :class:`numpy.ndarray` copy enabled state.

2052

2053 Parameters

2054 ----------

2055 enable

2056 Whether to enable *Colour* :class:`numpy.ndarray` copy.

2057

2058 Examples

2059 --------

2060 >>> with ndarray_copy_enable(is_ndarray_copy_enabled()):

2061 ... print(is_ndarray_copy_enabled())

2062 ... set_ndarray_copy_enable(False)

2063 ... print(is_ndarray_copy_enabled())

2064 True

2065 False

2066 """

2067

2068 global _NDARRAY_COPY_ENABLED # noqa: PLW0603

2069

2070 _NDARRAY_COPY_ENABLED = enable

2071

2072

2073 class ndarray_copy_enable:

2074 """

2075 Define a context manager and decorator to temporarily set the *Colour*

2076 :class:`numpy.ndarray` copy enabled state.

2077

2078 Parameters

2079 ----------

2080 enable

2081 Whether to enable or disable *Colour* :class:`numpy.ndarray` copy.

2082 """

2083

2084 def __init__(self, enable: bool) -> None:

2085 self._enable = enable

2086 self._previous_state = is_ndarray_copy_enabled()

2087

2088 def __enter__(self) -> Self:

2089 """

2090 Set the *Colour* :class:`numpy.ndarray` copy enabled state upon

2091 entering the context manager.

2092 """

2093

2094 set_ndarray_copy_enable(self._enable)

2095

2096 return self

2097

2098 def __exit__(self, *args: Any) -> None:

2099 """

2100 Restore the *Colour* :class:`numpy.ndarray` copy enabled state upon

2101 exiting the context manager.

2102 """

2103

2104 set_ndarray_copy_enable(self._previous_state)

2105

2106 def __call__(self, function: Callable) -> Callable:

2107 """

2108 Decorate and call the specified function with array copy control.

2109

2110 Parameters

2111 ----------

2112 function

2113 Function to be decorated with array copy state management.

2114

2115 Returns

2116 -------

2117 :class:`Callable`

2118 Decorated function that executes within the configured array copy

2119 state context.

2120 """

2121

2122 @functools.wraps(function)

2123 def wrapper(*args: Any, **kwargs: Any) -> Any:

2124 with self:

2125 return function(*args, **kwargs)

2126

2127 return wrapper

2128

2129

2130 def ndarray_copy(a: NDArray) -> NDArray:

2131 """

2132 Return a :class:`numpy.ndarray` copy if the relevant *Colour* state is

2133 enabled.

2134

2135 Various API objects return a copy of their internal

2136 :class:`numpy.ndarray` for safety purposes, but this can be a slow

2137 operation impacting performance.

2138

2139 Parameters

2140 ----------

2141 a

2142 Array :math:`a` to return a copy of.

2143

2144 Returns

2145 -------

2146 :class:`numpy.ndarray`

2147 Array :math:`a` copy according to *Colour* state.

2148

2149 Examples

2150 --------

2151 >>> a = np.linspace(0, 1, 10)

2152 >>> id(a) == id(ndarray_copy(a))

2153 False

2154 >>> with ndarray_copy_enable(False):

2155 ... id(a) == id(ndarray_copy(a))

2156 True

2157 """

2158

2159 if _NDARRAY_COPY_ENABLED:

2160 return np.copy(a)

2161 return a

2162

2163

2164 def closest_indexes(a: ArrayLike, b: ArrayLike) -> NDArray:

2165 """

2166 Return the closest element indexes from array :math:`a` to reference array

2167 :math:`b` elements.

2168

2169 Parameters

2170 ----------

2171 a

2172 Array :math:`a` to search for the closest elements.

2173 b

2174 Reference array :math:`b`.

2175

2176 Returns

2177 -------

2178 :class:`numpy.ndarray`

2179 Closest array :math:`a` element indexes.

2180

2181 Examples

2182 --------

2183 >>> a = np.array(

2184 ... [

2185 ... 24.31357115,

2186 ... 63.62396289,

2187 ... 55.71528816,

2188 ... 62.70988028,

2189 ... 46.84480573,

2190 ... 25.40026416,

2191 ... ]

2192 ... )

2193 >>> print(closest_indexes(a, 63))

2194 [3]

2195 >>> print(closest_indexes(a, [63, 25]))

2196 [3 5]

2197 """

2198

2199 a = np.ravel(a)[:, None]

2200 b = np.ravel(b)[None, :]

2201

2202 return np.abs(a - b).argmin(axis=0)

2203

2204

2205 def closest(a: ArrayLike, b: ArrayLike) -> NDArray:

2206 """

2207 Return the closest array :math:`a` elements to reference array

2208 :math:`b` elements.

2209

2210 Parameters

2211 ----------

2212 a

2213 Array :math:`a` to search for the closest elements.

2214 b

2215 Reference array :math:`b`.

2216

2217 Returns

2218 -------

2219 :class:`numpy.ndarray`

2220 Closest array :math:`a` elements.

2221

2222 Examples

2223 --------

2224 >>> a = np.array(

2225 ... [

2226 ... 24.31357115,

2227 ... 63.62396289,

2228 ... 55.71528816,

2229 ... 62.70988028,

2230 ... 46.84480573,

2231 ... 25.40026416,

2232 ... ]

2233 ... )

2234 >>> closest(a, 63)

2235 array([ 62.70988028])

2236 >>> closest(a, [63, 25])

2237 array([ 62.70988028, 25.40026416])

2238 """

2239

2240 a = np.array(a)

2241

2242 return a[closest_indexes(a, b)]

2243

2244

2245 _CACHE_DISTRIBUTION_INTERVAL: dict = CACHE_REGISTRY.register_cache(

2246 f"{__name__}._CACHE_DISTRIBUTION_INTERVAL"

2247)

2248

2249

2250 def interval(distribution: ArrayLike, unique: bool = True) -> NDArray:

2251 """

2252 Return the interval size of the specified distribution.

2253

2254 Parameters

2255 ----------

2256 distribution

2257 Distribution to retrieve the interval from.

2258 unique

2259 Whether to return unique intervals if the distribution is

2260 non-uniformly spaced or the complete intervals.

2261

2262 Returns

2263 -------

2264 :class:`numpy.ndarray`

2265 Distribution interval.

2266

2267 Examples

2268 --------

2269 Uniformly spaced variable:

2270

2271 >>> y = np.array([1, 2, 3, 4, 5])

2272 >>> interval(y)

2273 array([ 1.])

2274 >>> interval(y, False)

2275 array([ 1., 1., 1., 1.])

2276

2277 Non-uniformly spaced variable:

2278

2279 >>> y = np.array([1, 2, 3, 4, 8])

2280 >>> interval(y)

2281 array([ 1., 4.])

2282 >>> interval(y, False)

2283 array([ 1., 1., 1., 4.])

2284 """

2285

2286 distribution = as_float_array(distribution)

2287 hash_key = hash(

2288 (

2289 int_digest(distribution.tobytes()),

2290 distribution.shape,

2291 unique,

2292 )

2293 )

2294

2295 if is_caching_enabled() and hash_key in _CACHE_DISTRIBUTION_INTERVAL:

2296 return np.copy(_CACHE_DISTRIBUTION_INTERVAL[hash_key])

2297

2298 differences = np.abs(distribution[1:] - distribution[:-1])

2299

2300 if unique and np.all(differences == differences[0]):

2301 interval_ = np.array([differences[0]])

2302 elif unique:

2303 interval_ = np.unique(differences)

2304 else:

2305 interval_ = differences

2306

2307 _CACHE_DISTRIBUTION_INTERVAL[hash_key] = np.copy(interval_)

2308

2309 return interval_

2310

2311

2312 def is_uniform(distribution: ArrayLike) -> bool:

2313 """

2314 Determine whether the specified distribution is uniform.

2315

2316 Parameters

2317 ----------

2318 distribution

2319 Distribution to check for uniformity.

2320

2321 Returns

2322 -------

2323 :class:`bool`

2324 Whether the distribution is uniform.

2325

2326 Examples

2327 --------

2328 Uniformly spaced variable:

2329

2330 >>> a = np.array([1, 2, 3, 4, 5])

2331 >>> is_uniform(a)

2332 True

2333

2334 Non-uniformly spaced variable:

2335

2336 >>> a = np.array([1, 2, 3.1415, 4, 5])

2337 >>> is_uniform(a)

2338 False

2339 """

2340

2341 return bool(interval(distribution).size == 1)

2342

2343

2344 def in_array(a: ArrayLike, b: ArrayLike, tolerance: Real = EPSILON) -> NDArray:

2345 """

2346 Determine whether each element of array :math:`a` is present in array

2347 :math:`b` within the specified tolerance.

2348

2349 Parameters

2350 ----------

2351 a

2352 Array :math:`a` to test the elements from.

2353 b

2354 Array :math:`b` against which to test the elements of array

2355 :math:`a`.

2356 tolerance

2357 Tolerance value.

2358

2359 Returns

2360 -------

2361 :class:`numpy.ndarray`

2362 Boolean array with array :math:`a` shape indicating whether each

2363 element of array :math:`a` is present in array :math:`b` within the

2364 specified tolerance.

2365

2366 References

2367 ----------

2368 :cite:`Yorke2014a`

2369

2370 Examples

2371 --------

2372 >>> a = np.array([0.50, 0.60])

2373 >>> b = np.linspace(0, 10, 101)

2374 >>> np.isin(a, b)

2375 array([ True, False], dtype=bool)

2376 >>> in_array(a, b)

2377 array([ True, True], dtype=bool)

2378 """

2379

2380 a = as_float_array(a)

2381 b = as_float_array(b)

2382

2383 d = np.abs(np.ravel(a) - b[..., None])

2384

2385 return np.reshape(np.any(d <= tolerance, axis=0), a.shape)

2386

2387

2388 def tstack(

2389 a: ArrayLike,

2390 dtype: Type[DTypeBoolean] | Type[DTypeReal] | None = None,

2391) -> NDArray:

2392 """

2393 Stack the specified array of arrays :math:`a` along the last axis (tail)

2394 to produce a stacked array.

2395

2396 Used to stack an array of arrays produced by the

2397 :func:`colour.utilities.tsplit` definition.

2398

2399 Parameters

2400 ----------

2401 a

2402 Array of arrays :math:`a` to stack along the last axis.

2403 dtype

2404 :class:`numpy.dtype` to use for initial conversion to

2405 :class:`numpy.ndarray`, default to the :class:`numpy.dtype` defined

2406 by :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

2407

2408 Returns

2409 -------

2410 :class:`numpy.ndarray`

2411 Stacked array.

2412

2413 Examples

2414 --------

2415 >>> a = 0

2416 >>> tstack([a, a, a])

2417 array([ 0., 0., 0.])

2418 >>> a = np.arange(0, 6)

2419 >>> tstack([a, a, a])

2420 array([[ 0., 0., 0.],

2421 [ 1., 1., 1.],

2422 [ 2., 2., 2.],

2423 [ 3., 3., 3.],

2424 [ 4., 4., 4.],

2425 [ 5., 5., 5.]])

2426 >>> a = np.reshape(a, (1, 6))

2427 >>> tstack([a, a, a])

2428 array([[[ 0., 0., 0.],

2429 [ 1., 1., 1.],

2430 [ 2., 2., 2.],

2431 [ 3., 3., 3.],

2432 [ 4., 4., 4.],

2433 [ 5., 5., 5.]]])

2434 >>> a = np.reshape(a, (1, 1, 6))

2435 >>> tstack([a, a, a])

2436 array([[[[ 0., 0., 0.],

2437 [ 1., 1., 1.],

2438 [ 2., 2., 2.],

2439 [ 3., 3., 3.],

2440 [ 4., 4., 4.],

2441 [ 5., 5., 5.]]]])

2442 """

2443

2444 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

2445

2446 a = as_array(a, dtype)

2447

2448 return np.concatenate([x[..., np.newaxis] for x in a], axis=-1)

2449

2450

2451 def tsplit(

2452 a: ArrayLike,

2453 dtype: Type[DTypeBoolean] | Type[DTypeReal] | None = None,

2454) -> NDArray:

2455 """

2456 Split the specified stacked array :math:`a` along the last axis (tail)

2457 to produce an array of arrays.

2458

2459 Used to split a stacked array produced by the :func:`colour.utilities.tstack`

2460 definition.

2461

2462 Parameters

2463 ----------

2464 a

2465 Stacked array :math:`a` to split.

2466 dtype

2467 :class:`numpy.dtype` to use for initial conversion to

2468 :class:`numpy.ndarray`, default to the :class:`numpy.dtype` defined

2469 by :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

2470

2471 Returns

2472 -------

2473 :class:`numpy.ndarray`

2474 Array of arrays.

2475

2476 Examples

2477 --------

2478 >>> a = np.array([0, 0, 0])

2479 >>> tsplit(a)

2480 array([ 0., 0., 0.])

2481 >>> a = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5]])

2482 >>> tsplit(a)

2483 array([[ 0., 1., 2., 3., 4., 5.],

2484 [ 0., 1., 2., 3., 4., 5.],

2485 [ 0., 1., 2., 3., 4., 5.]])

2486 >>> a = np.array(

2487 ... [

2488 ... [

2489 ... [0, 0, 0],

2490 ... [1, 1, 1],

2491 ... [2, 2, 2],

2492 ... [3, 3, 3],

2493 ... [4, 4, 4],

2494 ... [5, 5, 5],

2495 ... ]

2496 ... ]

2497 ... )

2498 >>> tsplit(a)

2499 array([[[ 0., 1., 2., 3., 4., 5.]],

2500 <BLANKLINE>

2501 [[ 0., 1., 2., 3., 4., 5.]],

2502 <BLANKLINE>

2503 [[ 0., 1., 2., 3., 4., 5.]]])

2504 """

2505

2506 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

2507

2508 a = as_array(a, dtype)

2509

2510 return np.array([a[..., x] for x in range(a.shape[-1])])

2511

2512

2513 def row_as_diagonal(a: ArrayLike) -> NDArray:

2514 """

2515 Return the rows of the specified array :math:`a` as diagonal matrices.

2516

2517 Parameters

2518 ----------

2519 a

2520 Array :math:`a` to return the rows of as diagonal matrices.

2521

2522 Returns

2523 -------

2524 :class:`numpy.ndarray`

2525 Array :math:`a` rows as diagonal matrices.

2526

2527 References

2528 ----------

2529 :cite:`Castro2014a`

2530

2531 Examples

2532 --------

2533 >>> a = np.array(

2534 ... [

2535 ... [0.25891593, 0.07299478, 0.36586996],

2536 ... [0.30851087, 0.37131459, 0.16274825],

2537 ... [0.71061831, 0.67718718, 0.09562581],

2538 ... [0.71588836, 0.76772047, 0.15476079],

2539 ... [0.92985142, 0.22263399, 0.88027331],

2540 ... ]

2541 ... )

2542 >>> row_as_diagonal(a)

2543 array([[[ 0.25891593, 0. , 0. ],

2544 [ 0. , 0.07299478, 0. ],

2545 [ 0. , 0. , 0.36586996]],

2546 <BLANKLINE>

2547 [[ 0.30851087, 0. , 0. ],

2548 [ 0. , 0.37131459, 0. ],

2549 [ 0. , 0. , 0.16274825]],

2550 <BLANKLINE>

2551 [[ 0.71061831, 0. , 0. ],

2552 [ 0. , 0.67718718, 0. ],

2553 [ 0. , 0. , 0.09562581]],

2554 <BLANKLINE>

2555 [[ 0.71588836, 0. , 0. ],

2556 [ 0. , 0.76772047, 0. ],

2557 [ 0. , 0. , 0.15476079]],

2558 <BLANKLINE>

2559 [[ 0.92985142, 0. , 0. ],

2560 [ 0. , 0.22263399, 0. ],

2561 [ 0. , 0. , 0.88027331]]])

2562 """

2563

2564 d = as_array(a)

2565

2566 d = np.expand_dims(d, -2)

2567

2568 return np.eye(d.shape[-1]) * d

2569

2570

2571 def orient(

2572 a: ArrayLike,

2573 orientation: (

2574 Literal["Ignore", "Flip", "Flop", "90 CW", "90 CCW", "180"] | str

2575 ) = "Ignore",

2576) -> NDArray:

2577 """

2578 Orient the specified array :math:`a` using the specified orientation.

2579

2580 Parameters

2581 ----------

2582 a

2583 Array :math:`a` to orient.

2584 orientation

2585 Orientation to perform.

2586

2587 Returns

2588 -------

2589 :class:`numpy.ndarray`

2590 Oriented array.

2591

2592 Examples

2593 --------

2594 >>> a = np.tile(np.arange(5), (5, 1))

2595 >>> a

2596 array([[0, 1, 2, 3, 4],

2597 [0, 1, 2, 3, 4],

2598 [0, 1, 2, 3, 4],

2599 [0, 1, 2, 3, 4],

2600 [0, 1, 2, 3, 4]])

2601 >>> orient(a, "90 CW")

2602 array([[ 0., 0., 0., 0., 0.],

2603 [ 1., 1., 1., 1., 1.],

2604 [ 2., 2., 2., 2., 2.],

2605 [ 3., 3., 3., 3., 3.],

2606 [ 4., 4., 4., 4., 4.]])

2607 >>> orient(a, "Flip")

2608 array([[ 4., 3., 2., 1., 0.],

2609 [ 4., 3., 2., 1., 0.],

2610 [ 4., 3., 2., 1., 0.],

2611 [ 4., 3., 2., 1., 0.],

2612 [ 4., 3., 2., 1., 0.]])

2613 """

2614

2615 a = as_float_array(a)

2616

2617 orientation = validate_method(

2618 orientation, ("Ignore", "Flip", "Flop", "90 CW", "90 CCW", "180")

2619 )

2620

2621 if orientation == "ignore":

2622 oriented = a

2623 elif orientation == "flip":

2624 oriented = np.fliplr(a)

2625 elif orientation == "flop":

2626 oriented = np.flipud(a)

2627 elif orientation == "90 cw":

2628 oriented = np.rot90(a, 3)

2629 elif orientation == "90 ccw":

2630 oriented = np.rot90(a)

2631 elif orientation == "180":

2632 oriented = np.rot90(a, 2)

2633

2634 return oriented

2635

2636

2637 def centroid(a: ArrayLike) -> NDArrayInt:

2638 """

2639 Return the centroid indexes of the specified array :math:`a`.

2640

2641 Parameters

2642 ----------

2643 a

2644 Array :math:`a` to return the centroid indexes of.

2645

2646 Returns

2647 -------

2648 :class:`numpy.ndarray`

2649 Centroid indexes of array :math:`a`.

2650

2651 Examples

2652 --------

2653 >>> a = np.tile(np.arange(0, 5), (5, 1))

2654 >>> centroid(a) # doctest: +ELLIPSIS

2655 array([2, 3]...)

2656 """

2657

2658 a = as_float_array(a)

2659

2660 a_s = np.sum(a)

2661

2662 ranges = [np.arange(0, a.shape[i]) for i in range(a.ndim)]

2663 coordinates = np.meshgrid(*ranges)

2664

2665 a_ci = []

2666 for axis in coordinates:

2667 axis = np.transpose(axis) # noqa: PLW2901

2668 # Aligning axis for N-D arrays where N is normalised to

2669 # range [3, :math:`\\\infty`]

2670 for i in range(axis.ndim - 2, 0, -1):

2671 axis = np.rollaxis(axis, i - 1, axis.ndim) # noqa: PLW2901

2672

2673 a_ci.append(np.sum(axis * a) // a_s)

2674

2675 # NOTE: Cannot use `as_int_array` as presence of NaN will raise a ValueError

2676 # exception.

2677 return np.array(a_ci).astype(DTYPE_INT_DEFAULT)

2678

2679

2680 def fill_nan(

2681 a: ArrayLike,

2682 method: Literal["Interpolation", "Constant"] | str = "Interpolation",

2683 default: Real = 0,

2684) -> NDArray:

2685 """

2686 Fill the NaN values in the specified array :math:`a` using the specified

2687 method.

2688

2689 Parameters

2690 ----------

2691 a

2692 Array :math:`a` to fill the NaNs of.

2693 method

2694 *Interpolation* method linearly interpolates through the NaN values,

2695 *Constant* method replaces NaN values with ``default``.

2696 default

2697 Value to use with the *Constant* method.

2698

2699 Returns

2700 -------

2701 :class:`numpy.ndarray`

2702 NaN-filled array :math:`a`.

2703

2704 Examples

2705 --------

2706 >>> a = np.array([0.1, 0.2, np.nan, 0.4, 0.5])

2707 >>> fill_nan(a)

2708 array([ 0.1, 0.2, 0.3, 0.4, 0.5])

2709 >>> fill_nan(a, method="Constant")

2710 array([ 0.1, 0.2, 0. , 0.4, 0.5])

2711 """

2712

2713 a = np.array(a, copy=True)

2714 method = validate_method(method, ("Interpolation", "Constant"))

2715

2716 mask = np.isnan(a)

2717

2718 if method == "interpolation":

2719 a[mask] = np.interp(np.flatnonzero(mask), np.flatnonzero(~mask), a[~mask])

2720 elif method == "constant":

2721 a[mask] = default

2722

2723 return a

2724

2725

2726 def has_only_nan(a: ArrayLike) -> bool:

2727 """

2728 Return whether the specified array :math:`a` contains only *NaN* values.

2729

2730 Parameters

2731 ----------

2732 a

2733 Array :math:`a` to check whether it contains only *NaN* values.

2734

2735 Returns

2736 -------

2737 :class:`bool`

2738 Whether array :math:`a` contains only *NaN* values.

2739

2740 Examples

2741 --------

2742 >>> has_only_nan(None)

2743 True

2744 >>> has_only_nan([None, None])

2745 True

2746 >>> has_only_nan([True, None])

2747 False

2748 >>> has_only_nan([0.1, np.nan, 0.3])

2749 False

2750 """

2751

2752 a = as_float_array(a)

2753

2754 return bool(np.all(np.isnan(a)))

2755

2756

2757@contextmanager

2758 def ndarray_write(a: ArrayLike) -> Generator:

2759 """

2760 Define a context manager that temporarily sets the specified array

2761 :math:`a` to writeable for operations, then restores it to read-only.

2762

2763 Parameters

2764 ----------

2765 a

2766 Array :math:`a` to operate on.

2767

2768 Yields

2769 ------

2770 Generator

2771 Array :math:`a` made temporarily writeable.

2772

2773 Examples

2774 --------

2775 >>> a = np.linspace(0, 1, 10)

2776 >>> a.setflags(write=False)

2777 >>> try:

2778 ... a += 1

2779 ... except ValueError:

2780 ... pass

2781 >>> with ndarray_write(a):

2782 ... a += 1

2783 """

2784

2785 a = as_float_array(a)

2786

2787 a.setflags(write=True)

2788

2789 try:

2790 yield a

2791 finally:

2792 a.setflags(write=False)

2793

2794

2795 def zeros(

2796 shape: int | Sequence[int],

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

2798 order: Literal["C", "F"] = "C",

2799) -> NDArray:

2800 """

2801 Create an array of zeros with the active dtype.

2802

2803 Wrap :func:`np.zeros` definition to create an array with the active

2804 :class:`numpy.dtype` defined by the

2805 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

2806

2807 Parameters

2808 ----------

2809 shape

2810 Shape of the new array, e.g., ``(2, 3)`` or ``2``.

2811 dtype

2812 :class:`numpy.dtype` to use for conversion, default to the

2813 :class:`numpy.dtype` defined by the

2814 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

2815 order

2816 Whether to store multi-dimensional data in row-major

2817 (C-style) or column-major (Fortran-style) order in memory.

2818

2819 Returns

2820 -------

2821 :class:`numpy.ndarray`

2822 Array of the specified shape and :class:`numpy.dtype`, filled

2823 with zeros.

2824

2825 Examples

2826 --------

2827 >>> zeros(3)

2828 array([ 0., 0., 0.])

2829 """

2830

2831 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

2832

2833 return np.zeros(shape, dtype, order)

2834

2835

2836 def ones(

2837 shape: int | Sequence[int],

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

2839 order: Literal["C", "F"] = "C",

2840) -> NDArray:

2841 """

2842 Create an array of ones with the active dtype.

2843

2844 Wrap :func:`np.ones` definition to create an array with the active

2845 :class:`numpy.dtype` defined by the

2846 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

2847

2848 Parameters

2849 ----------

2850 shape

2851 Shape of the new array, e.g., ``(2, 3)`` or ``2``.

2852 dtype

2853 :class:`numpy.dtype` to use for conversion, default to the

2854 :class:`numpy.dtype` defined by the

2855 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

2856 order

2857 Whether to store multi-dimensional data in row-major (C-style) or

2858 column-major (Fortran-style) order in memory.

2859

2860 Returns

2861 -------

2862 :class:`numpy.ndarray`

2863 Array of the specified shape and :class:`numpy.dtype`, filled with ones.

2864

2865 Examples

2866 --------

2867 >>> ones(3)

2868 array([ 1., 1., 1.])

2869 """

2870

2871 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

2872

2873 return np.ones(shape, dtype, order)

2874

2875

2876 def full(

2877 shape: int | Sequence[int],

2878 fill_value: Real,

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

2880 order: Literal["C", "F"] = "C",

2881) -> NDArray:

2882 """

2883 Create an array of the specified value with the active dtype.

2884

2885 Wrap :func:`np.full` definition to create an array with the active

2886 :class:`numpy.dtype` defined by the

2887 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

2888

2889 Parameters

2890 ----------

2891 shape

2892 Shape of the new array, e.g., ``(2, 3)`` or ``2``.

2893 fill_value

2894 Fill value.

2895 dtype

2896 :class:`numpy.dtype` to use for conversion, default to the

2897 :class:`numpy.dtype` defined by the

2898 :attr:`colour.constant.DTYPE_FLOAT_DEFAULT` attribute.

2899 order

2900 Whether to store multi-dimensional data in row-major (C-style) or

2901 column-major (Fortran-style) order in memory.

2902

2903 Returns

2904 -------

2905 :class:`numpy.ndarray`

2906 Array of the specified shape and :class:`numpy.dtype`, filled with

2907 the specified value.

2908

2909 Examples

2910 --------

2911 >>> full(3, 2.5)

2912 array([ 2.5, 2.5, 2.5])

2913 """

2914

2915 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

2916

2917 return np.full(shape, fill_value, dtype, order)

2918

2919

2920 def index_along_last_axis(a: ArrayLike, indexes: ArrayLike) -> NDArray:

2921 """

2922 Reduce the dimension of array :math:`a` by one, using an array of

2923 indexes to select elements from the last axis.

2924

2925 Parameters

2926 ----------

2927 a

2928 Array :math:`a` to be indexed.

2929 indexes

2930 *Integer* array with the same shape as :math:`a` but with one

2931 dimension fewer, containing indices to the last dimension of

2932 :math:`a`. All elements must be numbers between 0 and

2933 :math:`m - 1`.

2934

2935 Returns

2936 -------

2937 :class:`numpy.ndarray`

2938 Indexed array :math:`a`.

2939

2940 Raises

2941 ------

2942 :class:`ValueError`

2943 If the array :math:`a` and ``indexes`` have incompatible shapes.

2944 :class:`IndexError`

2945 If ``indexes`` has elements outside of the allowed range of 0 to

2946 :math:`m - 1` or if it is not an *integer* array.

2947

2948 Examples

2949 --------

2950 >>> a = np.array(

2951 ... [

2952 ... [

2953 ... [0.3, 0.5, 6.9],

2954 ... [3.3, 4.4, 1.6],

2955 ... [4.4, 7.5, 2.3],

2956 ... [2.3, 1.6, 7.4],

2957 ... ],

2958 ... [

2959 ... [2.0, 5.9, 2.8],

2960 ... [6.2, 4.9, 8.6],

2961 ... [3.7, 9.7, 7.3],

2962 ... [6.3, 4.3, 3.2],

2963 ... ],

2964 ... [

2965 ... [0.8, 1.9, 0.7],

2966 ... [5.6, 4.0, 1.7],

2967 ... [6.7, 8.2, 1.7],

2968 ... [1.2, 7.1, 1.4],

2969 ... ],

2970 ... [

2971 ... [4.0, 4.8, 8.9],

2972 ... [4.0, 0.3, 6.9],

2973 ... [3.5, 7.1, 4.5],

2974 ... [1.4, 1.9, 1.6],

2975 ... ],

2976 ... ]

2977 ... )

2978 >>> indexes = np.array([[2, 0, 1, 1], [2, 1, 1, 0], [0, 0, 1, 2], [0, 0, 1, 2]])

2979 >>> index_along_last_axis(a, indexes)

2980 array([[ 6.9, 3.3, 7.5, 1.6],

2981 [ 2.8, 4.9, 9.7, 6.3],

2982 [ 0.8, 5.6, 8.2, 1.4],

2983 [ 4. , 4. , 7.1, 1.6]])

2984

2985 This function can be used to compute the result of :func:`np.min` along

2986 the last axis given the corresponding :func:`np.argmin` indexes.

2987

2988 >>> indexes = np.argmin(a, axis=-1)

2989 >>> np.array_equal(index_along_last_axis(a, indexes), np.min(a, axis=-1))

2990 True

2991

2992 In particular, this can be used to manipulate the indexes specified by

2993 functions like :func:`np.min` before indexing the array. For example, to

2994 get elements directly following the smallest elements:

2995

2996 >>> index_along_last_axis(a, (indexes + 1) % 3)

2997 array([[ 0.5, 3.3, 4.4, 7.4],

2998 [ 5.9, 8.6, 9.7, 6.3],

2999 [ 0.8, 5.6, 6.7, 7.1],

3000 [ 4.8, 6.9, 7.1, 1.9]])

3001 """

3002

3003 a = np.array(a)

3004 indexes = np.array(indexes)

3005

3006 if a.shape[:-1] != indexes.shape:

3007 error = (

3008 f"Array and indexes have incompatible shapes: {a.shape} and {indexes.shape}"

3009 )

3010

3011 raise ValueError(error)

3012

3013 return np.take_along_axis(a, indexes[..., None], axis=-1).squeeze(axis=-1)

3014

3015

3016 def format_array_as_row(a: ArrayLike, decimals: int = 7, separator: str = " ") -> str:

3017 """

3018 Format the specified array :math:`a` as a row.

3019

3020 Parameters

3021 ----------

3022 a

3023 Array to format.

3024 decimals

3025 Decimal count to use when formatting as a row.

3026 separator

3027 Separator used to join the array :math:`a` items.

3028

3029 Returns

3030 -------

3031 :class:`str`

3032 Array formatted as a row.

3033

3034 Examples

3035 --------

3036 >>> format_array_as_row([1.25, 2.5, 3.75])

3037 '1.2500000 2.5000000 3.7500000'

3038 >>> format_array_as_row([1.25, 2.5, 3.75], 3)

3039 '1.250 2.500 3.750'

3040 >>> format_array_as_row([1.25, 2.5, 3.75], 3, ", ")

3041 '1.250, 2.500, 3.750'

3042 """

3043

3044 a = np.ravel(a)

3045

3046 return separator.join(

3047 "{1:0.{0}f}".format(decimals, x)

3048 for x in a # noqa: PLE1300, RUF100

3049 )