Coverage for algebra/common.py: 69%

1"""

2 Common Utilities

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

4

5 Define common algebra utility objects that do not fall within any specific

6 category.

7

8 The *Common* sub-package provides general-purpose mathematical and

9 computational utilities used throughout the colour science library.

10"""

11

12 from __future__ import annotations

13

14 import functools

15 import typing

16

17 import numpy as np

18

19 if typing.TYPE_CHECKING:

20 from colour.hints import (

21 Any,

22 ArrayLike,

23 Callable,

24 DTypeFloat,

25 NDArray,

26 NDArrayFloat,

27 Self,

28 Tuple,

29 )

30

31 from colour.constants import EPSILON

32 from colour.hints import Literal, cast

33 from colour.utilities import (

34 as_float,

35 as_float_array,

36 optional,

37 runtime_warning,

38 tsplit,

39 validate_method,

40)

41

42 __author__ = "Colour Developers"

43 __copyright__ = "Copyright 2013 Colour Developers"

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

45 __maintainer__ = "Colour Developers"

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

47 __status__ = "Production"

48

49 __all__ = [

50 "get_sdiv_mode",

51 "set_sdiv_mode",

52 "sdiv_mode",

53 "sdiv",

54 "is_spow_enabled",

55 "set_spow_enable",

56 "spow_enable",

57 "spow",

58 "normalise_vector",

59 "normalise_maximum",

60 "vecmul",

61 "euclidean_distance",

62 "manhattan_distance",

63 "linear_conversion",

64 "linstep_function",

65 "lerp",

66 "smoothstep_function",

67 "smooth",

68 "is_identity",

69 "eigen_decomposition",

70]

71

72 _SDIV_MODE: Literal[

73 "Numpy",

74 "Ignore",

75 "Warning",

76 "Raise",

77 "Ignore Zero Conversion",

78 "Warning Zero Conversion",

79 "Ignore Limit Conversion",

80 "Warning Limit Conversion",

81 "Replace With Epsilon",

82 "Warning Replace With Epsilon",

83] = "Ignore Zero Conversion"

84"""

85 Global variable storing the current *Colour* safe division function mode.

86"""

87

88

89 def get_sdiv_mode() -> Literal[

90 "Numpy",

91 "Ignore",

92 "Warning",

93 "Raise",

94 "Ignore Zero Conversion",

95 "Warning Zero Conversion",

96 "Ignore Limit Conversion",

97 "Warning Limit Conversion",

98 "Replace With Epsilon",

99 "Warning Replace With Epsilon",

100]:

101 """

102 Return the current *Colour* safe division mode.

103

104 Returns

105 -------

106 :class:`str`

107 Current *Colour* safe division mode. See

108 :func:`colour.algebra.sdiv` definition for an explanation of

109 the possible modes.

110

111 Examples

112 --------

113 >>> with sdiv_mode("Numpy"):

114 ... get_sdiv_mode()

115 'numpy'

116 >>> with sdiv_mode("Ignore Zero Conversion"):

117 ... get_sdiv_mode()

118 'ignore zero conversion'

119 """

120

121 return _SDIV_MODE

122

123

124 def set_sdiv_mode(

125 mode: (

126 Literal[

127 "Numpy",

128 "Ignore",

129 "Warning",

130 "Raise",

131 "Ignore Zero Conversion",

132 "Warning Zero Conversion",

133 "Ignore Limit Conversion",

134 "Warning Limit Conversion",

135 "Replace With Epsilon",

136 "Warning Replace With Epsilon",

137 ]

138 | str

139 ),

140) -> None:

141 """

142 Set the *Colour* safe division function mode.

143

144 Parameters

145 ----------

146 mode

147 *Colour* safe division mode. See :func:`colour.algebra.sdiv`

148 definition for an explanation of the possible modes.

149

150 Examples

151 --------

152 >>> with sdiv_mode(get_sdiv_mode()):

153 ... print(get_sdiv_mode())

154 ... set_sdiv_mode("Raise")

155 ... print(get_sdiv_mode())

156 ignore zero conversion

157 raise

158 """

159

160 global _SDIV_MODE # noqa: PLW0603

161

162 _SDIV_MODE = cast(

163 "Literal['Numpy', 'Ignore', 'Warning', 'Raise', " # pyright: ignore

164 "'Ignore Zero Conversion', 'Warning Zero Conversion', "

165 "'Ignore Limit Conversion', 'Warning Limit Conversion', "

166 "'Replace With Epsilon', 'Warning Replace With Epsilon']",

167 validate_method(

168 mode,

169 (

170 "Numpy",

171 "Ignore",

172 "Warning",

173 "Raise",

174 "Ignore Zero Conversion",

175 "Warning Zero Conversion",

176 "Ignore Limit Conversion",

177 "Warning Limit Conversion",

178 "Replace With Epsilon",

179 "Warning Replace With Epsilon",

180 ),

181 ),

182 )

183

184

185 class sdiv_mode:

186 """

187 Context manager and decorator for temporarily modifying *Colour* safe

188 division function mode.

189

190 This utility enables temporary modification of the safe division behavior

191 in *Colour* computations, allowing control over how division operations

192 handle edge cases such as division by zero or near-zero values. The

193 context manager ensures automatic restoration of the original mode upon

194 exit.

195

196 Parameters

197 ----------

198 mode

199 *Colour* safe division function mode, see :func:`colour.algebra.sdiv`

200 definition for an explanation about the possible modes.

201 """

202

203 def __init__(

204 self,

205 mode: (

206 Literal[

207 "Numpy",

208 "Ignore",

209 "Warning",

210 "Raise",

211 "Ignore Zero Conversion",

212 "Warning Zero Conversion",

213 "Ignore Limit Conversion",

214 "Warning Limit Conversion",

215 "Replace With Epsilon",

216 "Warning Replace With Epsilon",

217 ]

218 | None

219 ) = None,

220 ) -> None:

221 self._mode = optional(mode, get_sdiv_mode())

222 self._previous_mode = get_sdiv_mode()

223

224 def __enter__(self) -> Self:

225 """

226 Set the *Colour* safe/symmetrical power function state to the

227 specified value upon entering the context manager.

228 """

229

230 set_sdiv_mode(self._mode)

231

232 return self

233

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

235 """

236 Restore the *Colour* safe / symmetrical power function enabled state

237 upon exiting the context manager.

238 """

239

240 set_sdiv_mode(self._previous_mode)

241

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

243 """

244 Call the wrapped definition.

245

246 The decorator applies the specified spectral power distribution

247 state to the wrapped function during its execution.

248 """

249

250 @functools.wraps(function)

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

252 with self:

253 return function(*args, **kwargs)

254

255 return wrapper

256

257

258 def sdiv(a: ArrayLike, b: ArrayLike) -> NDArrayFloat:

259 """

260 Perform safe division of array :math:`a` by array :math:`b` while

261 handling zero-division cases.

262

263 Avoid NaN and +/- inf generation when array :math:`b` contains zero

264 values. The zero-division handling behaviour is controlled by the

265 :func:`colour.algebra.set_sdiv_mode` definition or the

266 :func:`sdiv_mode` context manager. The following modes are available:

267

268 - ``Numpy``: The current *Numpy* zero-division handling occurs.

269 - ``Ignore``: Zero-division occurs silently.

270 - ``Warning``: Zero-division occurs with a warning.

271 - ``Ignore Zero Conversion``: Zero-division occurs silently and NaNs

272 or +/- infs values are converted to zeros. See

273 :func:`numpy.nan_to_num` definition for more details.

274 - ``Warning Zero Conversion``: Zero-division occurs with a warning

275 and NaNs or +/- infs values are converted to zeros. See

276 :func:`numpy.nan_to_num` definition for more details.

277 - ``Ignore Limit Conversion``: Zero-division occurs silently and

278 NaNs or +/- infs values are converted to zeros or the largest +/-

279 finite floating point values representable by the division result

280 :class:`numpy.dtype`. See :func:`numpy.nan_to_num` definition for

281 more details.

282 - ``Warning Limit Conversion``: Zero-division occurs with a warning

283 and NaNs or +/- infs values are converted to zeros or the largest

284 +/- finite floating point values representable by the division

285 result :class:`numpy.dtype`.

286 - ``Replace With Epsilon``: Zero-division is avoided by replacing

287 zero denominators with the machine epsilon value from

288 :attr:`colour.constants.EPSILON`.

289 - ``Warning Replace With Epsilon``: Zero-division is avoided by

290 replacing zero denominators with the machine epsilon value from

291 :attr:`colour.constants.EPSILON` with a warning.

292

293 Parameters

294 ----------

295 a

296 Numerator array :math:`a`.

297 b

298 Denominator array :math:`b`.

299

300 Returns

301 -------

302 :class:`np.float` or :class:`numpy.ndarray`

303 Array :math:`a` safely divided by :math:`b`.

304

305 Examples

306 --------

307 >>> a = np.array([0, 1, 2])

308 >>> b = np.array([2, 1, 0])

309 >>> sdiv(a, b)

310 array([ 0., 1., 0.])

311 >>> try:

312 ... with sdiv_mode("Raise"):

313 ... sdiv(a, b)

314 ... except Exception as error:

315 ... error # doctest: +ELLIPSIS

316 FloatingPointError('divide by zero encountered in...divide')

317 >>> with sdiv_mode("Ignore Zero Conversion"):

318 ... sdiv(a, b)

319 array([ 0., 1., 0.])

320 >>> with sdiv_mode("Warning Zero Conversion"):

321 ... sdiv(a, b)

322 array([ 0., 1., 0.])

323 >>> with sdiv_mode("Ignore Limit Conversion"):

324 ... sdiv(a, b) # doctest: +SKIP

325 array([ 0.00000000e+000, 1.00000000e+000, 1.79769313e+308])

326 >>> with sdiv_mode("Warning Limit Conversion"):

327 ... sdiv(a, b) # doctest: +SKIP

328 array([ 0.00000000e+000, 1.00000000e+000, 1.79769313e+308])

329 >>> with sdiv_mode("Replace With Epsilon"):

330 ... sdiv(a, b) # doctest: +ELLIPSIS

331 array([ 0.00000000e+00, 1.00000000e+00, ...])

332 >>> with sdiv_mode("Warning Replace With Epsilon"):

333 ... sdiv(a, b) # doctest: +ELLIPSIS

334 array([ 0.00000000e+00, 1.00000000e+00, ...])

335 """

336

337 a = as_float_array(a)

338 b = as_float_array(b)

339

340 mode = validate_method(

341 _SDIV_MODE,

342 (

343 "Numpy",

344 "Ignore",

345 "Warning",

346 "Raise",

347 "Ignore Zero Conversion",

348 "Warning Zero Conversion",

349 "Ignore Limit Conversion",

350 "Warning Limit Conversion",

351 "Replace With Epsilon",

352 "Warning Replace With Epsilon",

353 ),

354 )

355

356 if mode == "numpy":

357 c = a / b

358 elif mode == "ignore":

359 with np.errstate(divide="ignore", invalid="ignore"):

360 c = a / b

361 elif mode == "warning":

362 with np.errstate(divide="warn", invalid="warn"):

363 c = a / b

364 elif mode == "raise":

365 with np.errstate(divide="raise", invalid="raise"):

366 c = a / b

367 elif mode == "ignore zero conversion":

368 with np.errstate(divide="ignore", invalid="ignore"):

369 c = np.nan_to_num(a / b, nan=0, posinf=0, neginf=0)

370 elif mode == "warning zero conversion":

371 with np.errstate(divide="warn", invalid="warn"):

372 c = np.nan_to_num(a / b, nan=0, posinf=0, neginf=0)

373 elif mode == "ignore limit conversion":

374 with np.errstate(divide="ignore", invalid="ignore"):

375 c = np.nan_to_num(a / b)

376 elif mode == "warning limit conversion":

377 with np.errstate(divide="warn", invalid="warn"):

378 c = np.nan_to_num(a / b)

379 elif mode == "replace with epsilon":

380 b = np.where(b == 0, EPSILON, b)

381 c = a / b

382 elif mode == "warning replace with epsilon":

383 if np.any(b == 0):

384 runtime_warning("Zero(s) detected in denominator, replacing with EPSILON.")

385 b = np.where(b == 0, EPSILON, b)

386 c = a / b

387

388 return c

389

390

391 _SPOW_ENABLED: bool = True

392"""

393 Global variable storing the current *Colour* safe / symmetrical power function

394 enabled state.

395"""

396

397

398 def is_spow_enabled() -> bool:

399 """

400 Return whether *Colour* safe / symmetrical power function is enabled.

401

402 Returns

403 -------

404 :class:`bool`

405 Whether *Colour* safe / symmetrical power function is enabled.

406

407 Examples

408 --------

409 >>> with spow_enable(False):

410 ... is_spow_enabled()

411 False

412 >>> with spow_enable(True):

413 ... is_spow_enabled()

414 True

415 """

416

417 return _SPOW_ENABLED

418

419

420 def set_spow_enable(enable: bool) -> None:

421 """

422 Set the *Colour* safe/symmetrical power function enabled state.

423

424 Parameters

425 ----------

426 enable

427 Whether to enable the *Colour* safe/symmetrical power function.

428

429 Examples

430 --------

431 >>> with spow_enable(is_spow_enabled()):

432 ... print(is_spow_enabled())

433 ... set_spow_enable(False)

434 ... print(is_spow_enabled())

435 True

436 False

437 """

438

439 global _SPOW_ENABLED # noqa: PLW0603

440

441 _SPOW_ENABLED = enable

442

443

444 class spow_enable:

445 """

446 Context manager and decorator for temporarily setting the state of *Colour*

447 safe/symmetrical power function.

448

449 This utility provides both context manager and decorator functionality to

450 temporarily enable or disable the safe/symmetrical power function used

451 throughout the *Colour* library. When enabled, power operations use a

452 symmetrical implementation that handles negative values appropriately for

453 colour science computations.

454

455 Parameters

456 ----------

457 enable

458 Whether to enable or disable the *Colour* safe/symmetrical power

459 function for the duration of the context or decorated function.

460 """

461

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

463 self._enable = enable

464 self._previous_state = is_spow_enabled()

465

466 def __enter__(self) -> Self:

467 """

468 Set the *Colour* safe / symmetrical power function enabled state

469 upon entering the context manager.

470 """

471

472 set_spow_enable(self._enable)

473

474 return self

475

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

477 """

478 Set the *Colour* safe / symmetrical power function enabled state

479 upon exiting the context manager.

480 """

481

482 set_spow_enable(self._previous_state)

483

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

485 """Call the wrapped definition."""

486

487 @functools.wraps(function)

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

489 with self:

490 return function(*args, **kwargs)

491

492 return wrapper

493

494

495@typing.overload

496 def spow(a: float | DTypeFloat, p: float | DTypeFloat) -> DTypeFloat: ...

497@typing.overload

498 def spow(a: NDArray, p: ArrayLike) -> NDArrayFloat: ...

499@typing.overload

500 def spow(a: ArrayLike, p: NDArray) -> NDArrayFloat: ...

501@typing.overload

502 def spow(a: ArrayLike, p: ArrayLike) -> DTypeFloat | NDArrayFloat: ...

503 def spow(a: ArrayLike, p: ArrayLike) -> DTypeFloat | NDArrayFloat:

504 """

505 Raise specified array :math:`a` to the power :math:`p` as follows:

506 :math:`\\text{sign}(a) \\cdot |a|^p`.

507

508 This definition avoids NaN generation when array :math:`a` is negative

509 and power :math:`p` is fractional. This behaviour can be enabled or

510 disabled with the :func:`colour.algebra.set_spow_enable` definition or

511 with the :func:`spow_enable` context manager.

512

513 Parameters

514 ----------

515 a

516 Array :math:`a`.

517 p

518 Power :math:`p`.

519

520 Returns

521 -------

522 :class:`np.float` or :class:`numpy.ndarray`

523 Array :math:`a` safely raised to the power :math:`p`.

524

525 Examples

526 --------

527 >>> np.power(-2, 0.15)

528 nan

529 >>> spow(-2, 0.15) # doctest: +ELLIPSIS

530 -1.1095694...

531 >>> spow(0, 0)

532 0.0

533 """

534

535 if not _SPOW_ENABLED:

536 return np.power(a, p)

537

538 a = as_float_array(a)

539 p = as_float_array(p)

540

541 a_p = np.sign(a) * np.abs(a) ** p

542

543 return as_float(0 if a_p.ndim == 0 and np.isnan(a_p) else a_p)

544

545

546 def normalise_vector(a: ArrayLike) -> NDArrayFloat:

547 """

548 Normalise the specified vector :math:`a`.

549

550 The normalisation process scales the vector to have unit length, ensuring

551 that the magnitude of the resulting vector equals 1.

552

553 Parameters

554 ----------

555 a

556 Vector :math:`a` to normalise.

557

558 Returns

559 -------

560 :class:`numpy.ndarray`

561 Normalised vector :math:`a` with unit length.

562

563 Examples

564 --------

565 >>> a = np.array([0.20654008, 0.12197225, 0.05136952])

566 >>> normalise_vector(a) # doctest: +ELLIPSIS

567 array([ 0.8419703..., 0.4972256..., 0.2094102...])

568 """

569

570 a = as_float_array(a)

571

572 with sdiv_mode():

573 return sdiv(a, np.linalg.norm(a))

574

575

576 def normalise_maximum(

577 a: ArrayLike,

578 axis: int | None = None,

579 factor: float = 1,

580 clip: bool = True,

581) -> NDArrayFloat:

582 """

583 Normalise specified array :math:`a` values by :math:`a` maximum value

584 and optionally clip them between [0, factor].

585

586 Parameters

587 ----------

588 a

589 Array :math:`a` to normalise.

590 axis

591 Normalization axis.

592 factor

593 Normalization factor.

594 clip

595 Clip values to domain [0, 'factor'].

596

597 Returns

598 -------

599 :class:`numpy.ndarray`

600 Maximum normalised array :math:`a`.

601

602 Examples

603 --------

604 >>> a = np.array([0.48222001, 0.31654775, 0.22070353])

605 >>> normalise_maximum(a) # doctest: +ELLIPSIS

606 array([ 1. , 0.6564384..., 0.4576822...])

607 """

608

609 a = as_float_array(a)

610

611 maximum = np.max(a, axis=axis)

612

613 with sdiv_mode():

614 a = a * sdiv(1, maximum[..., None]) * factor

615

616 return np.clip(a, 0, factor) if clip else a

617

618

619 def vecmul(m: ArrayLike, v: ArrayLike) -> NDArrayFloat:

620 """

621 Perform batched multiplication between the matrix array :math:`m` and

622 vector array :math:`v`.

623

624 This function is equivalent to :func:`numpy.matmul` but specifically

625 designed for vector multiplication by a matrix. Vector dimensionality is

626 automatically increased to enable broadcasting. The operation can be

627 expressed using :func:`numpy.einsum` with subscripts

628 *'...ij,...j->...i'*.

629

630 Parameters

631 ----------

632 m

633 Matrix array :math:`m`.

634 v

635 Vector array :math:`v`.

636

637 Returns

638 -------

639 :class:`numpy.ndarray`

640 Multiplied vector array :math:`v`.

641

642 Examples

643 --------

644 >>> m = np.array(

645 ... [

646 ... [0.7328, 0.4296, -0.1624],

647 ... [-0.7036, 1.6975, 0.0061],

648 ... [0.0030, 0.0136, 0.9834],

649 ... ]

650 ... )

651 >>> m = np.reshape(np.tile(m, (6, 1)), (6, 3, 3))

652 >>> v = np.array([0.20654008, 0.12197225, 0.05136952])

653 >>> v = np.tile(v, (6, 1))

654 >>> vecmul(m, v) # doctest: +ELLIPSIS

655 array([[ 0.1954094..., 0.0620396..., 0.0527952...],

656 [ 0.1954094..., 0.0620396..., 0.0527952...],

657 [ 0.1954094..., 0.0620396..., 0.0527952...],

658 [ 0.1954094..., 0.0620396..., 0.0527952...],

659 [ 0.1954094..., 0.0620396..., 0.0527952...],

660 [ 0.1954094..., 0.0620396..., 0.0527952...]])

661 """

662

663 return np.matmul(as_float_array(m), as_float_array(v)[..., None]).squeeze(-1)

664

665

666 def euclidean_distance(a: ArrayLike, b: ArrayLike) -> NDArrayFloat:

667 """

668 Calculate the *Euclidean* distance between the specified point arrays

669 :math:`a` and :math:`b`.

670

671 For a two-dimensional space, the metric is as follows:

672

673 :math:`E_D = [(x_a - x_b)^2 + (y_a - y_b)^2]^{1/2}`

674

675 Parameters

676 ----------

677 a

678 Point array :math:`a`.

679 b

680 Point array :math:`b`.

681

682 Returns

683 -------

684 :class:`numpy.float64` or :class:`numpy.ndarray`

685 *Euclidean* distance between the two point arrays.

686

687 Examples

688 --------

689 >>> a = np.array([100.00000000, 21.57210357, 272.22819350])

690 >>> b = np.array([100.00000000, 426.67945353, 72.39590835])

691 >>> euclidean_distance(a, b) # doctest: +ELLIPSIS

692 451.7133019...

693 """

694

695 return as_float(np.linalg.norm(as_float_array(a) - as_float_array(b), axis=-1))

696

697

698 def manhattan_distance(a: ArrayLike, b: ArrayLike) -> NDArrayFloat:

699 """

700 Compute the *Manhattan* (or *City-Block*) distance between point array

701 :math:`a` and point array :math:`b`.

702

703 For a two-dimensional space, the metric is defined as:

704

705 :math:`M_D = |x_a - x_b| + |y_a - y_b|`

706

707 Parameters

708 ----------

709 a

710 Point array :math:`a`.

711 b

712 Point array :math:`b`.

713

714 Returns

715 -------

716 :class:`np.float` or :class:`numpy.ndarray`

717 *Manhattan* distance.

718

719 Examples

720 --------

721 >>> a = np.array([100.00000000, 21.57210357, 272.22819350])

722 >>> b = np.array([100.00000000, 426.67945353, 72.39590835])

723 >>> manhattan_distance(a, b) # doctest: +ELLIPSIS

724 604.9396351...

725 """

726

727 return as_float(np.sum(np.abs(as_float_array(a) - as_float_array(b)), axis=-1))

728

729

730 def linear_conversion(

731 a: ArrayLike, old_range: ArrayLike, new_range: ArrayLike

732) -> NDArrayFloat:

733 """

734 Perform simple linear conversion of the specified array :math:`a` between the

735 old and new ranges.

736

737 Parameters

738 ----------

739 a

740 Array :math:`a` to perform the linear conversion onto.

741 old_range

742 Old range.

743 new_range

744 New range.

745

746 Returns

747 -------

748 :class:`numpy.ndarray`

749 Linear conversion result.

750

751 Examples

752 --------

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

754 >>> linear_conversion(a, np.array([0, 1]), np.array([1, 10]))

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

756 """

757

758 a = as_float_array(a)

759

760 in_min, in_max = tsplit(old_range)

761 out_min, out_max = tsplit(new_range)

762

763 return ((a - in_min) / (in_max - in_min)) * (out_max - out_min) + out_min

764

765

766 def linstep_function(

767 x: ArrayLike,

768 a: ArrayLike = 0,

769 b: ArrayLike = 1,

770 clip: bool = False,

771) -> NDArrayFloat:

772 """

773 Perform linear interpolation between specified arrays :math:`a` and

774 :math:`b` using array :math:`x`.

775

776 Parameters

777 ----------

778 x

779 Array :math:`x` containing values to use for interpolation between

780 array :math:`a` and array :math:`b`.

781 a

782 Array :math:`a`, the start of the interpolation range.

783 b

784 Array :math:`b`, the end of the interpolation range.

785 clip

786 Whether to clip the output values to range [:math:`a`, :math:`b`].

787

788 Returns

789 -------

790 :class:`numpy.ndarray`

791 Linear interpolation result.

792

793 Examples

794 --------

795 >>> a = 0

796 >>> b = 2

797 >>> linstep_function(0.5, a, b)

798 1.0

799 """

800

801 x = as_float_array(x)

802 a = as_float_array(a)

803 b = as_float_array(b)

804

805 y = (1.0 - x) * a + x * b

806

807 return np.clip(y, a, b) if clip else y

808

809

810 lerp = linstep_function

811

812

813 def smoothstep_function(

814 x: ArrayLike,

815 a: ArrayLike = 0,

816 b: ArrayLike = 1,

817 clip: bool = False,

818) -> NDArrayFloat:

819 """

820 Apply the *smoothstep* cubic Hermite interpolation function to

821 array :math:`x`.

822

823 The *smoothstep* function creates a smooth S-shaped curve between

824 specified edge values, commonly used for smooth transitions in

825 colour interpolation and rendering operations.

826

827 Parameters

828 ----------

829 x

830 Input array :math:`x` containing values to be transformed.

831 a

832 Lower edge value for the interpolation domain.

833 b

834 Upper edge value for the interpolation domain.

835 clip

836 Whether to normalize and constrain input values to the domain

837 [:math:`a`, :math:`b`] before applying the *smoothstep* function.

838

839 Returns

840 -------

841 :class:`numpy.ndarray`

842 Transformed array with values smoothly interpolated using the

843 cubic Hermite polynomial :math:`3x^2 - 2x^3`.

844

845 Examples

846 --------

847 >>> x = np.linspace(-2, 2, 5)

848 >>> smoothstep_function(x, -2, 2, clip=True)

849 array([ 0. , 0.15625, 0.5 , 0.84375, 1. ])

850 """

851

852 x = as_float_array(x)

853 a = as_float_array(a)

854 b = as_float_array(b)

855

856 i = np.clip((x - a) / (b - a), 0, 1) if clip else x

857

858 return (i**2) * (3.0 - 2.0 * i)

859

860

861 smooth = smoothstep_function

862

863

864 def is_identity(a: ArrayLike) -> bool:

865 """

866 Determine whether the specified array :math:`a` is an identity matrix.

867

868 An identity matrix is a square matrix with ones on the main diagonal

869 and zeros elsewhere, satisfying :math:`I \\cdot A = A \\cdot I = A`

870 for any compatible matrix :math:`A`.

871

872 Parameters

873 ----------

874 a

875 Array :math:`a` to test for identity matrix properties.

876

877 Returns

878 -------

879 :class:`bool`

880 Whether the specified array :math:`a` is an identity matrix.

881

882 Examples

883 --------

884 >>> is_identity(np.reshape(np.array([1, 0, 0, 0, 1, 0, 0, 0, 1]), (3, 3)))

885 True

886 >>> is_identity(np.reshape(np.array([1, 2, 0, 0, 1, 0, 0, 0, 1]), (3, 3)))

887 False

888 """

889

890 return np.array_equal(np.identity(len(np.diag(a))), a)

891

892

893 def eigen_decomposition(

894 a: ArrayLike,

895 eigen_w_v_count: int | None = None,

896 descending_order: bool = True,

897 covariance_matrix: bool = False,

898) -> Tuple[NDArrayFloat, NDArrayFloat]:

899 """

900 Compute the eigenvalues :math:`w` and eigenvectors :math:`v` of the

901 specified array :math:`a` in the specified order.

902

903 Parameters

904 ----------

905 a

906 Array to compute the eigenvalues :math:`w` and eigenvectors :math:`v`

907 for.

908 eigen_w_v_count

909 Number of eigenvalues :math:`w` and eigenvectors :math:`v` to return.

910 descending_order

911 Whether to return the eigenvalues :math:`w` and eigenvectors :math:`v`

912 in descending order.

913 covariance_matrix

914 Whether to compute the eigenvalues :math:`w` and eigenvectors

915 :math:`v` of the array :math:`a` covariance matrix

916 :math:`A = a^T \\cdot a`.

917

918 Returns

919 -------

920 :class:`tuple`

921 Tuple of eigenvalues :math:`w` and eigenvectors :math:`v`. The

922 eigenvalues are in the specified order, each repeated according to

923 its multiplicity. The column ``v[:, i]`` is the normalized eigenvector

924 corresponding to the eigenvalue ``w[i]``.

925

926 Examples

927 --------

928 >>> a = np.diag([1, 2, 3])

929 >>> w, v = eigen_decomposition(a)

930 >>> w

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

932 >>> v

933 array([[ 0., 0., 1.],

934 [ 0., 1., 0.],

935 [ 1., 0., 0.]])

936 >>> w, v = eigen_decomposition(a, 1)

937 >>> w

938 array([ 3.])

939 >>> v

940 array([[ 0.],

941 [ 0.],

942 [ 1.]])

943 >>> w, v = eigen_decomposition(a, descending_order=False)

944 >>> w

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

946 >>> v

947 array([[ 1., 0., 0.],

948 [ 0., 1., 0.],

949 [ 0., 0., 1.]])

950 >>> w, v = eigen_decomposition(a, covariance_matrix=True)

951 >>> w

952 array([ 9., 4., 1.])

953 >>> v

954 array([[ 0., 0., 1.],

955 [ 0., 1., 0.],

956 [ 1., 0., 0.]])

957 """

958

959 A = as_float_array(a)

960

961 if covariance_matrix:

962 A = np.dot(np.transpose(A), A)

963

964 w, v = np.linalg.eigh(A)

965

966 if eigen_w_v_count is not None:

967 w = w[-eigen_w_v_count:]

968 v = v[..., -eigen_w_v_count:]

969

970 if descending_order:

971 w = np.flipud(w)

972 v = np.fliplr(v)

973

974 return w, v