phasorpy 0.7__cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phasorpy/phasor.py ADDED
@@ -0,0 +1,2018 @@
1
+ """Calculate, convert, and reduce phasor coordinates.
2
+
3
+ The ``phasorpy.phasor`` module provides functions to:
4
+
5
+ - calculate phasor coordinates from time-resolved and spectral signals:
6
+
7
+ - :py:func:`phasor_from_signal`
8
+
9
+ - synthesize signals from phasor coordinates:
10
+
11
+ - :py:func:`phasor_to_signal`
12
+
13
+ - convert to and from polar coordinates (phase and modulation):
14
+
15
+ - :py:func:`phasor_from_polar`
16
+ - :py:func:`phasor_to_polar`
17
+
18
+ - transform phasor coordinates:
19
+
20
+ - :py:func:`phasor_transform`
21
+ - :py:func:`phasor_multiply`
22
+ - :py:func:`phasor_divide`
23
+ - :py:func:`phasor_normalize`
24
+
25
+ - reduce dimensionality of arrays of phasor coordinates:
26
+
27
+ - :py:func:`phasor_center`
28
+ - :py:func:`phasor_to_principal_plane`
29
+
30
+ - filter phasor coordinates:
31
+
32
+ - :py:func:`phasor_filter_median`
33
+ - :py:func:`phasor_filter_pawflim`
34
+ - :py:func:`phasor_threshold`
35
+
36
+ - find nearest neighbor phasor coordinates from other phasor coordinates:
37
+
38
+ - :py:func:`phasor_nearest_neighbor`
39
+
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ __all__ = [
45
+ 'phasor_center',
46
+ 'phasor_divide',
47
+ 'phasor_filter_median',
48
+ 'phasor_filter_pawflim',
49
+ 'phasor_from_polar',
50
+ 'phasor_from_signal',
51
+ 'phasor_multiply',
52
+ 'phasor_nearest_neighbor',
53
+ 'phasor_normalize',
54
+ 'phasor_threshold',
55
+ 'phasor_to_complex',
56
+ 'phasor_to_polar',
57
+ 'phasor_to_principal_plane',
58
+ 'phasor_to_signal',
59
+ 'phasor_transform',
60
+ ]
61
+
62
+ import math
63
+ from collections.abc import Sequence
64
+ from typing import TYPE_CHECKING
65
+
66
+ if TYPE_CHECKING:
67
+ from ._typing import (
68
+ Any,
69
+ NDArray,
70
+ ArrayLike,
71
+ DTypeLike,
72
+ Callable,
73
+ Literal,
74
+ )
75
+
76
+ import numpy
77
+
78
+ from ._phasorpy import (
79
+ _median_filter_2d,
80
+ _nearest_neighbor_2d,
81
+ _phasor_divide,
82
+ _phasor_from_polar,
83
+ _phasor_from_signal,
84
+ _phasor_multiply,
85
+ _phasor_threshold_closed,
86
+ _phasor_threshold_mean_closed,
87
+ _phasor_threshold_mean_open,
88
+ _phasor_threshold_nan,
89
+ _phasor_threshold_open,
90
+ _phasor_to_polar,
91
+ _phasor_transform,
92
+ _phasor_transform_const,
93
+ )
94
+ from ._utils import parse_harmonic, parse_signal_axis, parse_skip_axis
95
+ from .utils import number_threads
96
+
97
+
98
+ def phasor_from_signal(
99
+ signal: ArrayLike,
100
+ /,
101
+ *,
102
+ axis: int | str | None = None,
103
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
104
+ sample_phase: ArrayLike | None = None,
105
+ use_fft: bool | None = None,
106
+ rfft: Callable[..., NDArray[Any]] | None = None,
107
+ dtype: DTypeLike = None,
108
+ normalize: bool = True,
109
+ num_threads: int | None = None,
110
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
111
+ r"""Return phasor coordinates from signal.
112
+
113
+ Parameters
114
+ ----------
115
+ signal : array_like
116
+ Frequency-domain, time-domain, or hyperspectral data.
117
+ A minimum of three samples are required along `axis`.
118
+ The samples must be uniformly spaced.
119
+ axis : int or str, optional
120
+ Axis over which to compute phasor coordinates.
121
+ By default, the 'H' or 'C' axes if signal contains such dimension
122
+ names, else the last axis (-1).
123
+ harmonic : int, sequence of int, or 'all', optional
124
+ Harmonics to return.
125
+ If `'all'`, return all harmonics for `signal` samples along `axis`.
126
+ Else, harmonics must be at least one and no larger than half the
127
+ number of `signal` samples along `axis`.
128
+ The default is the first harmonic (fundamental frequency).
129
+ A minimum of `harmonic * 2 + 1` samples are required along `axis`
130
+ to calculate correct phasor coordinates at `harmonic`.
131
+ sample_phase : array_like, optional
132
+ Phase values (in radians) of `signal` samples along `axis`.
133
+ If None (default), samples are assumed to be uniformly spaced along
134
+ one period.
135
+ The array size must equal the number of samples along `axis`.
136
+ Cannot be used with `harmonic!=1` or `use_fft=True`.
137
+ use_fft : bool, optional
138
+ If true, use a real forward Fast Fourier Transform (FFT).
139
+ If false, use a Cython implementation that is optimized (faster and
140
+ resource saving) for calculating few harmonics.
141
+ By default, FFT is only used when all or at least 8 harmonics are
142
+ calculated, or `rfft` is specified.
143
+ rfft : callable, optional
144
+ Drop-in replacement function for ``numpy.fft.rfft``.
145
+ For example, ``scipy.fft.rfft`` or ``mkl_fft._numpy_fft.rfft``.
146
+ Used to calculate the real forward FFT.
147
+ dtype : dtype_like, optional
148
+ Data type of output arrays. Either float32 or float64.
149
+ The default is float64 unless the `signal` is float32.
150
+ normalize : bool, optional
151
+ Return normalized phasor coordinates.
152
+ If true (default), return average of `signal` along `axis` and
153
+ Fourier coefficients divided by sum of `signal` along `axis`.
154
+ Else, return sum of `signal` along `axis` and unscaled Fourier
155
+ coefficients.
156
+ Un-normalized phasor coordinates cannot be used with most of PhasorPy's
157
+ functions but may be required for intermediate processing.
158
+ num_threads : int, optional
159
+ Number of OpenMP threads to use for parallelization when not using FFT.
160
+ By default, multi-threading is disabled.
161
+ If zero, up to half of logical CPUs are used.
162
+ OpenMP may not be available on all platforms.
163
+
164
+ Returns
165
+ -------
166
+ mean : ndarray
167
+ Average of `signal` along `axis` (zero harmonic).
168
+ real : ndarray
169
+ Real component of phasor coordinates at `harmonic` along `axis`.
170
+ imag : ndarray
171
+ Imaginary component of phasor coordinates at `harmonic` along `axis`.
172
+
173
+ Raises
174
+ ------
175
+ ValueError
176
+ The `signal` has less than three samples along `axis`.
177
+ The `sample_phase` size does not equal the number of samples along
178
+ `axis`.
179
+ IndexError
180
+ `harmonic` is smaller than 1 or greater than half the samples along
181
+ `axis`.
182
+ TypeError
183
+ The `signal`, `dtype`, or `harmonic` types are not supported.
184
+
185
+ See Also
186
+ --------
187
+ phasorpy.phasor.phasor_to_signal
188
+ phasorpy.phasor.phasor_normalize
189
+ :ref:`sphx_glr_tutorials_misc_phasorpy_phasor_from_signal.py`
190
+
191
+ Notes
192
+ -----
193
+ The normalized phasor coordinates `real` (:math:`G`), `imag` (:math:`S`),
194
+ and average intensity `mean` (:math:`F_{DC}`) are calculated from
195
+ :math:`K \ge 3` samples of the signal :math:`F` at `harmonic` :math:`h`
196
+ according to:
197
+
198
+ .. math::
199
+
200
+ F_{DC} &= \frac{1}{K} \sum_{k=0}^{K-1} F_{k}
201
+
202
+ G &= \frac{1}{K} \sum_{k=0}^{K-1} F_{k}
203
+ \cos{\left (2 \pi h \frac{k}{K} \right )} \cdot \frac{1}{F_{DC}}
204
+
205
+ S &= \frac{1}{K} \sum_{k=0}^{K-1} F_{k}
206
+ \sin{\left (2 \pi h \frac{k}{K} \right )} \cdot \frac{1}{F_{DC}}
207
+
208
+ If :math:`F_{DC} = 0`, the phasor coordinates are undefined
209
+ (resulting in NaN or infinity).
210
+ Use NaN-aware software to further process the phasor coordinates.
211
+
212
+ The phasor coordinates may be zero, for example, in case of only constant
213
+ background in time-resolved signals, or as the result of linear
214
+ combination of non-zero spectral phasors coordinates.
215
+
216
+ Examples
217
+ --------
218
+ Calculate phasor coordinates of a phase-shifted sinusoidal waveform:
219
+
220
+ >>> sample_phase = numpy.linspace(0, 2 * math.pi, 5, endpoint=False)
221
+ >>> signal = 1.1 * (numpy.cos(sample_phase - 0.785398) * 2 * 0.707107 + 1)
222
+ >>> phasor_from_signal(signal) # doctest: +NUMBER
223
+ (array(1.1), array(0.5), array(0.5))
224
+
225
+ The sinusoidal signal does not have a second harmonic component:
226
+
227
+ >>> phasor_from_signal(signal, harmonic=2) # doctest: +NUMBER
228
+ (array(1.1), array(0.0), array(0.0))
229
+
230
+ """
231
+ # TODO: C-order not required by rfft?
232
+ # TODO: preserve array subtypes?
233
+
234
+ axis, _ = parse_signal_axis(signal, axis)
235
+
236
+ signal = numpy.asarray(signal, order='C')
237
+ if signal.dtype.kind not in 'uif':
238
+ raise TypeError(f'signal must be real valued, not {signal.dtype=}')
239
+ samples = numpy.size(signal, axis) # this also verifies axis and ndim >= 1
240
+ if samples < 3:
241
+ raise ValueError(f'not enough {samples=} along {axis=}')
242
+
243
+ if dtype is None:
244
+ dtype = numpy.float32 if signal.dtype.char == 'f' else numpy.float64
245
+ dtype = numpy.dtype(dtype)
246
+ if dtype.kind != 'f':
247
+ raise TypeError(f'{dtype=} not supported')
248
+
249
+ harmonic, keepdims = parse_harmonic(harmonic, samples // 2)
250
+ num_harmonics = len(harmonic)
251
+
252
+ if sample_phase is not None:
253
+ if use_fft:
254
+ raise ValueError('sample_phase cannot be used with FFT')
255
+ if num_harmonics > 1 or harmonic[0] != 1:
256
+ raise ValueError('sample_phase cannot be used with harmonic != 1')
257
+ sample_phase = numpy.atleast_1d(
258
+ numpy.asarray(sample_phase, dtype=numpy.float64)
259
+ )
260
+ if sample_phase.ndim != 1 or sample_phase.size != samples:
261
+ raise ValueError(f'{sample_phase.shape=} != ({samples},)')
262
+
263
+ if use_fft is None:
264
+ use_fft = sample_phase is None and (
265
+ rfft is not None
266
+ or num_harmonics > 7
267
+ or num_harmonics >= samples // 2
268
+ )
269
+
270
+ if use_fft:
271
+ if rfft is None:
272
+ rfft = numpy.fft.rfft
273
+
274
+ fft: NDArray[Any] = rfft(
275
+ signal, axis=axis, norm='forward' if normalize else 'backward'
276
+ )
277
+
278
+ mean = fft.take(0, axis=axis).real
279
+ if not mean.ndim == 0:
280
+ mean = numpy.ascontiguousarray(mean, dtype)
281
+ fft = fft.take(harmonic, axis=axis)
282
+ real = numpy.ascontiguousarray(fft.real, dtype)
283
+ imag = numpy.ascontiguousarray(fft.imag, dtype)
284
+ del fft
285
+
286
+ if not keepdims and real.shape[axis] == 1:
287
+ dc = mean
288
+ real = real.squeeze(axis)
289
+ imag = imag.squeeze(axis)
290
+ else:
291
+ # make broadcastable
292
+ dc = numpy.expand_dims(mean, 0)
293
+ real = numpy.moveaxis(real, axis, 0)
294
+ imag = numpy.moveaxis(imag, axis, 0)
295
+
296
+ if normalize:
297
+ with numpy.errstate(divide='ignore', invalid='ignore'):
298
+ real /= dc
299
+ imag /= dc
300
+ numpy.negative(imag, out=imag)
301
+
302
+ if not keepdims and real.ndim == 0:
303
+ return mean.squeeze(), real.squeeze(), imag.squeeze()
304
+
305
+ return mean, real, imag
306
+
307
+ num_threads = number_threads(num_threads)
308
+
309
+ sincos = numpy.empty((num_harmonics, samples, 2))
310
+ for i, h in enumerate(harmonic):
311
+ if sample_phase is None:
312
+ phase = numpy.linspace(
313
+ 0,
314
+ h * math.pi * 2.0,
315
+ samples,
316
+ endpoint=False,
317
+ dtype=numpy.float64,
318
+ )
319
+ else:
320
+ phase = sample_phase
321
+ sincos[i, :, 0] = numpy.cos(phase)
322
+ sincos[i, :, 1] = numpy.sin(phase)
323
+
324
+ # reshape to 3D with axis in middle
325
+ axis = axis % signal.ndim
326
+ shape0 = signal.shape[:axis]
327
+ shape1 = signal.shape[axis + 1 :]
328
+ size0 = math.prod(shape0)
329
+ size1 = math.prod(shape1)
330
+ phasor = numpy.empty((num_harmonics * 2 + 1, size0, size1), dtype)
331
+ signal = signal.reshape((size0, samples, size1))
332
+
333
+ _phasor_from_signal(phasor, signal, sincos, normalize, num_threads)
334
+
335
+ # restore original shape
336
+ shape = shape0 + shape1
337
+ mean = phasor[0].reshape(shape)
338
+ if keepdims:
339
+ shape = (num_harmonics,) + shape
340
+ real = phasor[1 : num_harmonics + 1].reshape(shape)
341
+ imag = phasor[1 + num_harmonics :].reshape(shape)
342
+ if shape:
343
+ return mean, real, imag
344
+ return mean.squeeze(), real.squeeze(), imag.squeeze()
345
+
346
+
347
+ def phasor_to_signal(
348
+ mean: ArrayLike,
349
+ real: ArrayLike,
350
+ imag: ArrayLike,
351
+ /,
352
+ *,
353
+ samples: int = 64,
354
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
355
+ axis: int = -1,
356
+ irfft: Callable[..., NDArray[Any]] | None = None,
357
+ ) -> NDArray[numpy.float64]:
358
+ """Return signal from phasor coordinates using inverse Fourier transform.
359
+
360
+ Parameters
361
+ ----------
362
+ mean : array_like
363
+ Average signal intensity (DC).
364
+ If not scalar, shape must match the last dimensions of `real`.
365
+ real : array_like
366
+ Real component of phasor coordinates.
367
+ Multiple harmonics, if any, must be in the first axis.
368
+ imag : array_like
369
+ Imaginary component of phasor coordinates.
370
+ Must be same shape as `real`.
371
+ samples : int, default: 64
372
+ Number of signal samples to return. Must be at least three.
373
+ harmonic : int, sequence of int, or 'all', optional
374
+ Harmonics included in first axis of `real` and `imag`.
375
+ If None, lower harmonics are inferred from the shapes of phasor
376
+ coordinates (most commonly, lower harmonics are present if the number
377
+ of dimensions of `mean` is one less than `real`).
378
+ If `'all'`, the harmonics in the first axis of phasor coordinates are
379
+ the lower harmonics necessary to synthesize `samples`.
380
+ Else, harmonics must be at least one and no larger than half of
381
+ `samples`.
382
+ The phasor coordinates of missing harmonics are zeroed
383
+ if `samples` is greater than twice the number of harmonics.
384
+ axis : int, optional
385
+ Axis at which to return signal samples.
386
+ The default is the last axis (-1).
387
+ irfft : callable, optional
388
+ Drop-in replacement function for ``numpy.fft.irfft``.
389
+ For example, ``scipy.fft.irfft`` or ``mkl_fft._numpy_fft.irfft``.
390
+ Used to calculate the real inverse FFT.
391
+
392
+ Returns
393
+ -------
394
+ signal : ndarray
395
+ Reconstructed signal with samples of one period along the last axis.
396
+
397
+ See Also
398
+ --------
399
+ phasorpy.phasor.phasor_from_signal
400
+
401
+ Notes
402
+ -----
403
+ The reconstructed signal may be undefined if the input phasor coordinates,
404
+ or signal mean contain NaN values.
405
+
406
+ Examples
407
+ --------
408
+ Reconstruct exact signal from phasor coordinates at all harmonics:
409
+
410
+ >>> sample_phase = numpy.linspace(0, 2 * math.pi, 5, endpoint=False)
411
+ >>> signal = 1.1 * (numpy.cos(sample_phase - 0.785398) * 2 * 0.707107 + 1)
412
+ >>> signal
413
+ array([2.2, 2.486, 0.8566, -0.4365, 0.3938])
414
+ >>> phasor_to_signal(
415
+ ... *phasor_from_signal(signal, harmonic='all'),
416
+ ... harmonic='all',
417
+ ... samples=len(signal)
418
+ ... ) # doctest: +NUMBER
419
+ array([2.2, 2.486, 0.8566, -0.4365, 0.3938])
420
+
421
+ Reconstruct a single-frequency waveform from phasor coordinates at
422
+ first harmonic:
423
+
424
+ >>> phasor_to_signal(1.1, 0.5, 0.5, samples=5) # doctest: +NUMBER
425
+ array([2.2, 2.486, 0.8566, -0.4365, 0.3938])
426
+
427
+ """
428
+ if samples < 3:
429
+ raise ValueError(f'{samples=} < 3')
430
+
431
+ mean = numpy.array(mean, ndmin=0, copy=True)
432
+ real = numpy.array(real, ndmin=0, copy=True)
433
+ imag = numpy.array(imag, ndmin=1, copy=True)
434
+
435
+ harmonic_ = harmonic
436
+ harmonic, has_harmonic_axis = parse_harmonic(harmonic, samples // 2)
437
+
438
+ if real.ndim == 1 and len(harmonic) > 1 and real.shape[0] == len(harmonic):
439
+ # single axis contains harmonic
440
+ has_harmonic_axis = True
441
+ real = real[..., None]
442
+ imag = imag[..., None]
443
+ keepdims = mean.ndim > 0
444
+ else:
445
+ keepdims = mean.ndim > 0 or real.ndim > 0
446
+
447
+ mean = numpy.asarray(numpy.atleast_1d(mean))
448
+ real = numpy.asarray(numpy.atleast_1d(real))
449
+
450
+ if real.dtype.kind != 'f' or imag.dtype.kind != 'f':
451
+ raise ValueError(f'{real.dtype=} or {imag.dtype=} not floating point')
452
+ if real.shape != imag.shape:
453
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
454
+
455
+ if (
456
+ harmonic_ is None
457
+ and mean.size > 1
458
+ and mean.ndim + 1 == real.ndim
459
+ and mean.shape == real.shape[1:]
460
+ ):
461
+ # infer harmonic from shapes of mean and real
462
+ harmonic = list(range(1, real.shape[0] + 1))
463
+ has_harmonic_axis = True
464
+
465
+ if not has_harmonic_axis:
466
+ real = real[None, ...]
467
+ imag = imag[None, ...]
468
+
469
+ if len(harmonic) != real.shape[0]:
470
+ raise ValueError(f'{len(harmonic)=} != {real.shape[0]=}')
471
+
472
+ real *= mean
473
+ imag *= mean
474
+ numpy.negative(imag, out=imag)
475
+
476
+ fft: NDArray[Any] = numpy.zeros(
477
+ (samples // 2 + 1, *real.shape[1:]), dtype=numpy.complex128
478
+ )
479
+ fft.real[[0]] = mean
480
+ fft.real[harmonic] = real[: len(harmonic)]
481
+ fft.imag[harmonic] = imag[: len(harmonic)]
482
+
483
+ if irfft is None:
484
+ irfft = numpy.fft.irfft
485
+
486
+ signal: NDArray[Any] = irfft(fft, samples, axis=0, norm='forward')
487
+
488
+ if not keepdims:
489
+ signal = signal[:, 0]
490
+ elif axis != 0:
491
+ signal = numpy.moveaxis(signal, 0, axis)
492
+ return signal
493
+
494
+
495
+ def phasor_to_complex(
496
+ real: ArrayLike,
497
+ imag: ArrayLike,
498
+ /,
499
+ *,
500
+ dtype: DTypeLike = None,
501
+ ) -> NDArray[numpy.complex64 | numpy.complex128]:
502
+ """Return phasor coordinates as complex numbers.
503
+
504
+ Parameters
505
+ ----------
506
+ real : array_like
507
+ Real component of phasor coordinates.
508
+ imag : array_like
509
+ Imaginary component of phasor coordinates.
510
+ dtype : dtype_like, optional
511
+ Data type of output array. Either complex64 or complex128.
512
+ By default, complex64 if `real` and `imag` are float32,
513
+ else complex128.
514
+
515
+ Returns
516
+ -------
517
+ complex : ndarray
518
+ Phasor coordinates as complex numbers.
519
+
520
+ Examples
521
+ --------
522
+ Convert phasor coordinates to complex number arrays:
523
+
524
+ >>> phasor_to_complex([0.4, 0.5], [0.2, 0.3])
525
+ array([0.4+0.2j, 0.5+0.3j])
526
+
527
+ """
528
+ real = numpy.asarray(real)
529
+ imag = numpy.asarray(imag)
530
+ if dtype is None:
531
+ if real.dtype == numpy.float32 and imag.dtype == numpy.float32:
532
+ dtype = numpy.complex64
533
+ else:
534
+ dtype = numpy.complex128
535
+ else:
536
+ dtype = numpy.dtype(dtype)
537
+ if dtype.kind != 'c':
538
+ raise ValueError(f'{dtype=} not a complex type')
539
+
540
+ c = numpy.empty(numpy.broadcast(real, imag).shape, dtype)
541
+ c.real = real
542
+ c.imag = imag
543
+ return c
544
+
545
+
546
+ def phasor_multiply(
547
+ real: ArrayLike,
548
+ imag: ArrayLike,
549
+ factor_real: ArrayLike,
550
+ factor_imag: ArrayLike,
551
+ /,
552
+ **kwargs: Any,
553
+ ) -> tuple[NDArray[Any], NDArray[Any]]:
554
+ r"""Return complex multiplication of two phasors.
555
+
556
+ Complex multiplication can be used, for example, to convolve two signals
557
+ such as exponential decay and instrument response functions.
558
+
559
+ Parameters
560
+ ----------
561
+ real : array_like
562
+ Real component of phasor coordinates to multiply.
563
+ imag : array_like
564
+ Imaginary component of phasor coordinates to multiply.
565
+ factor_real : array_like
566
+ Real component of phasor coordinates to multiply by.
567
+ factor_imag : array_like
568
+ Imaginary component of phasor coordinates to multiply by.
569
+ **kwargs
570
+ Optional `arguments passed to numpy universal functions
571
+ <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
572
+
573
+ Returns
574
+ -------
575
+ real : ndarray
576
+ Real component of complex multiplication.
577
+ imag : ndarray
578
+ Imaginary component of complex multiplication.
579
+
580
+ Notes
581
+ -----
582
+ The phasor coordinates `real` (:math:`G`) and `imag` (:math:`S`)
583
+ are multiplied by phasor coordinates `factor_real` (:math:`g`)
584
+ and `factor_imag` (:math:`s`) according to:
585
+
586
+ .. math::
587
+
588
+ G' &= G \cdot g - S \cdot s
589
+
590
+ S' &= G \cdot s + S \cdot g
591
+
592
+ Examples
593
+ --------
594
+ Multiply two sets of phasor coordinates:
595
+
596
+ >>> phasor_multiply([0.1, 0.2], [0.3, 0.4], [0.5, 0.6], [0.7, 0.8])
597
+ (array([-0.16, -0.2]), array([0.22, 0.4]))
598
+
599
+ """
600
+ # c = phasor_to_complex(real, imag) * phasor_to_complex(
601
+ # factor_real, factor_imag
602
+ # )
603
+ # return c.real, c.imag
604
+ return _phasor_multiply( # type: ignore[no-any-return]
605
+ real, imag, factor_real, factor_imag, **kwargs
606
+ )
607
+
608
+
609
+ def phasor_divide(
610
+ real: ArrayLike,
611
+ imag: ArrayLike,
612
+ divisor_real: ArrayLike,
613
+ divisor_imag: ArrayLike,
614
+ /,
615
+ **kwargs: Any,
616
+ ) -> tuple[NDArray[Any], NDArray[Any]]:
617
+ r"""Return complex division of two phasors.
618
+
619
+ Complex division can be used, for example, to deconvolve two signals
620
+ such as exponential decay and instrument response functions.
621
+
622
+ Parameters
623
+ ----------
624
+ real : array_like
625
+ Real component of phasor coordinates to divide.
626
+ imag : array_like
627
+ Imaginary component of phasor coordinates to divide.
628
+ divisor_real : array_like
629
+ Real component of phasor coordinates to divide by.
630
+ divisor_imag : array_like
631
+ Imaginary component of phasor coordinates to divide by.
632
+ **kwargs
633
+ Optional `arguments passed to numpy universal functions
634
+ <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
635
+
636
+ Returns
637
+ -------
638
+ real : ndarray
639
+ Real component of complex division.
640
+ imag : ndarray
641
+ Imaginary component of complex division.
642
+
643
+ Notes
644
+ -----
645
+ The phasor coordinates `real` (:math:`G`) and `imag` (:math:`S`)
646
+ are divided by phasor coordinates `divisor_real` (:math:`g`)
647
+ and `divisor_imag` (:math:`s`) according to:
648
+
649
+ .. math::
650
+
651
+ d &= g \cdot g + s \cdot s
652
+
653
+ G' &= (G \cdot g + S \cdot s) / d
654
+
655
+ S' &= (G \cdot s - S \cdot g) / d
656
+
657
+ Examples
658
+ --------
659
+ Divide two sets of phasor coordinates:
660
+
661
+ >>> phasor_divide([-0.16, -0.2], [0.22, 0.4], [0.5, 0.6], [0.7, 0.8])
662
+ (array([0.1, 0.2]), array([0.3, 0.4]))
663
+
664
+ """
665
+ # c = phasor_to_complex(real, imag) / phasor_to_complex(
666
+ # divisor_real, divisor_imag
667
+ # )
668
+ # return c.real, c.imag
669
+ return _phasor_divide( # type: ignore[no-any-return]
670
+ real, imag, divisor_real, divisor_imag, **kwargs
671
+ )
672
+
673
+
674
+ def phasor_normalize(
675
+ mean_unnormalized: ArrayLike,
676
+ real_unnormalized: ArrayLike,
677
+ imag_unnormalized: ArrayLike,
678
+ /,
679
+ samples: int = 1,
680
+ dtype: DTypeLike = None,
681
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
682
+ r"""Return normalized phasor coordinates.
683
+
684
+ Use to normalize the phasor coordinates returned by
685
+ ``phasor_from_signal(..., normalize=False)``.
686
+
687
+ Parameters
688
+ ----------
689
+ mean_unnormalized : array_like
690
+ Unnormalized intensity of phasor coordinates.
691
+ real_unnormalized : array_like
692
+ Unnormalized real component of phasor coordinates.
693
+ imag_unnormalized : array_like
694
+ Unnormalized imaginary component of phasor coordinates.
695
+ samples : int, default: 1
696
+ Number of signal samples over which `mean` was integrated.
697
+ dtype : dtype_like, optional
698
+ Data type of output arrays. Either float32 or float64.
699
+ The default is float64 unless the `real` is float32.
700
+
701
+ Returns
702
+ -------
703
+ mean : ndarray
704
+ Normalized intensity.
705
+ real : ndarray
706
+ Normalized real component.
707
+ imag : ndarray
708
+ Normalized imaginary component.
709
+
710
+ Notes
711
+ -----
712
+ The average intensity `mean` (:math:`F_{DC}`) and normalized phasor
713
+ coordinates `real` (:math:`G`) and `imag` (:math:`S`) are calculated from
714
+ the signal `intensity` (:math:`F`), the number of `samples` (:math:`K`),
715
+ `real_unnormalized` (:math:`G'`), and `imag_unnormalized` (:math:`S'`)
716
+ according to:
717
+
718
+ .. math::
719
+
720
+ F_{DC} &= F / K
721
+
722
+ G &= G' / F
723
+
724
+ S &= S' / F
725
+
726
+ If :math:`F = 0`, the normalized phasor coordinates (:math:`G`)
727
+ and (:math:`S`) are undefined (NaN or infinity).
728
+
729
+ Examples
730
+ --------
731
+ Normalize phasor coordinates with intensity integrated over 10 samples:
732
+
733
+ >>> phasor_normalize([0.0, 0.1], [0.0, 0.3], [0.4, 0.5], samples=10)
734
+ (array([0, 0.01]), array([nan, 3]), array([inf, 5]))
735
+
736
+ Normalize multi-harmonic phasor coordinates:
737
+
738
+ >>> phasor_normalize(0.1, [0.0, 0.3], [0.4, 0.5], samples=10)
739
+ (array(0.01), array([0, 3]), array([4, 5]))
740
+
741
+ """
742
+ if samples < 1:
743
+ raise ValueError(f'{samples=} < 1')
744
+
745
+ if (
746
+ dtype is None
747
+ and isinstance(real_unnormalized, numpy.ndarray)
748
+ and real_unnormalized.dtype == numpy.float32
749
+ ):
750
+ real = real_unnormalized.copy()
751
+ else:
752
+ real = numpy.array(real_unnormalized, dtype, copy=True)
753
+ imag = numpy.array(imag_unnormalized, real.dtype, copy=True)
754
+ mean = numpy.array(mean_unnormalized, real.dtype, copy=True)
755
+
756
+ with numpy.errstate(divide='ignore', invalid='ignore'):
757
+ numpy.divide(real, mean, out=real)
758
+ numpy.divide(imag, mean, out=imag)
759
+ if samples > 1:
760
+ numpy.divide(mean, samples, out=mean)
761
+
762
+ return mean, real, imag
763
+
764
+
765
+ def phasor_transform(
766
+ real: ArrayLike,
767
+ imag: ArrayLike,
768
+ phase: ArrayLike = 0.0,
769
+ modulation: ArrayLike = 1.0,
770
+ /,
771
+ **kwargs: Any,
772
+ ) -> tuple[NDArray[Any], NDArray[Any]]:
773
+ r"""Return rotated and scaled phasor coordinates.
774
+
775
+ This function rotates and uniformly scales phasor coordinates around the
776
+ origin.
777
+ It can be used, for example, to calibrate phasor coordinates.
778
+
779
+ Parameters
780
+ ----------
781
+ real : array_like
782
+ Real component of phasor coordinates to transform.
783
+ imag : array_like
784
+ Imaginary component of phasor coordinates to transform.
785
+ phase : array_like, optional, default: 0.0
786
+ Rotation angle in radians.
787
+ modulation : array_like, optional, default: 1.0
788
+ Uniform scale factor.
789
+ **kwargs
790
+ Optional `arguments passed to numpy universal functions
791
+ <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
792
+
793
+ Returns
794
+ -------
795
+ real : ndarray
796
+ Real component of rotated and scaled phasor coordinates.
797
+ imag : ndarray
798
+ Imaginary component of rotated and scaled phasor coordinates.
799
+
800
+ Notes
801
+ -----
802
+ The phasor coordinates `real` (:math:`G`) and `imag` (:math:`S`)
803
+ are rotated by `phase` (:math:`\phi`)
804
+ and scaled by `modulation_zero` (:math:`M`)
805
+ around the origin according to:
806
+
807
+ .. math::
808
+
809
+ g &= M \cdot \cos{\phi}
810
+
811
+ s &= M \cdot \sin{\phi}
812
+
813
+ G' &= G \cdot g - S \cdot s
814
+
815
+ S' &= G \cdot s + S \cdot g
816
+
817
+ Examples
818
+ --------
819
+ Use scalar reference coordinates to rotate and scale phasor coordinates:
820
+
821
+ >>> phasor_transform(
822
+ ... [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], 0.1, 0.5
823
+ ... ) # doctest: +NUMBER
824
+ (array([0.0298, 0.0745, 0.119]), array([0.204, 0.259, 0.3135]))
825
+
826
+ Use separate reference coordinates for each phasor coordinate:
827
+
828
+ >>> phasor_transform(
829
+ ... [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.2, 0.2, 0.3], [0.5, 0.2, 0.3]
830
+ ... ) # doctest: +NUMBER
831
+ (array([0.00927, 0.0193, 0.0328]), array([0.206, 0.106, 0.1986]))
832
+
833
+ """
834
+ if numpy.ndim(phase) == 0 and numpy.ndim(modulation) == 0:
835
+ return _phasor_transform_const( # type: ignore[no-any-return]
836
+ real,
837
+ imag,
838
+ modulation * numpy.cos(phase),
839
+ modulation * numpy.sin(phase),
840
+ )
841
+ return _phasor_transform( # type: ignore[no-any-return]
842
+ real, imag, phase, modulation, **kwargs
843
+ )
844
+
845
+
846
+ def phasor_to_polar(
847
+ real: ArrayLike,
848
+ imag: ArrayLike,
849
+ /,
850
+ **kwargs: Any,
851
+ ) -> tuple[NDArray[Any], NDArray[Any]]:
852
+ r"""Return polar coordinates from phasor coordinates.
853
+
854
+ Parameters
855
+ ----------
856
+ real : array_like
857
+ Real component of phasor coordinates.
858
+ imag : array_like
859
+ Imaginary component of phasor coordinates.
860
+ **kwargs
861
+ Optional `arguments passed to numpy universal functions
862
+ <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
863
+
864
+ Notes
865
+ -----
866
+ The phasor coordinates `real` (:math:`G`) and `imag` (:math:`S`)
867
+ are converted to polar coordinates `phase` (:math:`\phi`) and
868
+ `modulation` (:math:`M`) according to:
869
+
870
+ .. math::
871
+
872
+ \phi &= \arctan(S / G)
873
+
874
+ M &= \sqrt{G^2 + S^2}
875
+
876
+ Returns
877
+ -------
878
+ phase : ndarray
879
+ Angular component of polar coordinates in radians.
880
+ modulation : ndarray
881
+ Radial component of polar coordinates.
882
+
883
+ See Also
884
+ --------
885
+ phasorpy.phasor.phasor_from_polar
886
+ :ref:`sphx_glr_tutorials_phasorpy_lifetime_geometry.py`
887
+
888
+ Examples
889
+ --------
890
+ Calculate polar coordinates from three phasor coordinates:
891
+
892
+ >>> phasor_to_polar([1.0, 0.5, 0.0], [0.0, 0.5, 1.0]) # doctest: +NUMBER
893
+ (array([0, 0.7854, 1.571]), array([1, 0.7071, 1]))
894
+
895
+ """
896
+ return _phasor_to_polar( # type: ignore[no-any-return]
897
+ real, imag, **kwargs
898
+ )
899
+
900
+
901
+ def phasor_from_polar(
902
+ phase: ArrayLike,
903
+ modulation: ArrayLike,
904
+ /,
905
+ **kwargs: Any,
906
+ ) -> tuple[NDArray[Any], NDArray[Any]]:
907
+ r"""Return phasor coordinates from polar coordinates.
908
+
909
+ Parameters
910
+ ----------
911
+ phase : array_like
912
+ Angular component of polar coordinates in radians.
913
+ modulation : array_like
914
+ Radial component of polar coordinates.
915
+ **kwargs
916
+ Optional `arguments passed to numpy universal functions
917
+ <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
918
+
919
+ Returns
920
+ -------
921
+ real : ndarray
922
+ Real component of phasor coordinates.
923
+ imag : ndarray
924
+ Imaginary component of phasor coordinates.
925
+
926
+ See Also
927
+ --------
928
+ phasorpy.phasor.phasor_to_polar
929
+
930
+ Notes
931
+ -----
932
+ The polar coordinates `phase` (:math:`\phi`) and `modulation` (:math:`M`)
933
+ are converted to phasor coordinates `real` (:math:`G`) and
934
+ `imag` (:math:`S`) according to:
935
+
936
+ .. math::
937
+
938
+ G &= M \cdot \cos{\phi}
939
+
940
+ S &= M \cdot \sin{\phi}
941
+
942
+ Examples
943
+ --------
944
+ Calculate phasor coordinates from three polar coordinates:
945
+
946
+ >>> phasor_from_polar(
947
+ ... [0.0, math.pi / 4, math.pi / 2], [1.0, math.sqrt(0.5), 1.0]
948
+ ... ) # doctest: +NUMBER
949
+ (array([1, 0.5, 0.0]), array([0, 0.5, 1]))
950
+
951
+ """
952
+ return _phasor_from_polar( # type: ignore[no-any-return]
953
+ phase, modulation, **kwargs
954
+ )
955
+
956
+
957
+ def phasor_to_principal_plane(
958
+ real: ArrayLike,
959
+ imag: ArrayLike,
960
+ /,
961
+ *,
962
+ reorient: bool = True,
963
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
964
+ """Return multi-harmonic phasor coordinates projected onto principal plane.
965
+
966
+ Principal component analysis (PCA) is used to project
967
+ multi-harmonic phasor coordinates onto a plane, along which
968
+ coordinate axes the phasor coordinates have the largest variations.
969
+
970
+ The transformed coordinates are not phasor coordinates. However, the
971
+ coordinates can be used in visualization and cursor analysis since
972
+ the transformation is affine (preserving collinearity and ratios
973
+ of distances).
974
+
975
+ Parameters
976
+ ----------
977
+ real : array_like
978
+ Real component of multi-harmonic phasor coordinates.
979
+ The first axis is the frequency dimension.
980
+ If less than 2-dimensional, size-1 dimensions are prepended.
981
+ imag : array_like
982
+ Imaginary component of multi-harmonic phasor coordinates.
983
+ Must be of same shape as `real`.
984
+ reorient : bool, optional, default: True
985
+ Reorient coordinates for easier visualization.
986
+ The projected coordinates are rotated and scaled, such that
987
+ the center lies in same quadrant and the projection
988
+ of [1, 0] lies at [1, 0].
989
+
990
+ Returns
991
+ -------
992
+ x : ndarray
993
+ X-coordinates of projected phasor coordinates.
994
+ If not `reorient`, this is the coordinate on the first principal axis.
995
+ The shape is ``real.shape[1:]``.
996
+ y : ndarray
997
+ Y-coordinates of projected phasor coordinates.
998
+ If not `reorient`, this is the coordinate on the second principal axis.
999
+ transformation_matrix : ndarray
1000
+ Affine transformation matrix used to project phasor coordinates.
1001
+ The shape is ``(2, 2 * real.shape[0])``.
1002
+
1003
+ See Also
1004
+ --------
1005
+ :ref:`sphx_glr_tutorials_api_phasorpy_pca.py`
1006
+
1007
+ Notes
1008
+ -----
1009
+
1010
+ This implementation does not work with coordinates containing
1011
+ undefined NaN values.
1012
+
1013
+ The transformation matrix can be used to project multi-harmonic phasor
1014
+ coordinates, where the first axis is the frequency:
1015
+
1016
+ .. code-block:: python
1017
+
1018
+ x, y = numpy.dot(
1019
+ numpy.vstack(
1020
+ real.reshape(real.shape[0], -1),
1021
+ imag.reshape(imag.shape[0], -1),
1022
+ ),
1023
+ transformation_matrix,
1024
+ ).reshape(2, *real.shape[1:])
1025
+
1026
+ An application of PCA to full-harmonic phasor coordinates from MRI signals
1027
+ can be found in [1]_.
1028
+
1029
+ References
1030
+ ----------
1031
+ .. [1] Franssen WMJ, Vergeldt FJ, Bader AN, van Amerongen H, and Terenzi C.
1032
+ `Full-harmonics phasor analysis: unravelling multiexponential trends
1033
+ in magnetic resonance imaging data
1034
+ <https://doi.org/10.1021/acs.jpclett.0c02319>`_.
1035
+ *J Phys Chem Lett*, 11(21): 9152-9158 (2020)
1036
+
1037
+ Examples
1038
+ --------
1039
+ The phasor coordinates of multi-exponential decays may be almost
1040
+ indistinguishable at certain frequencies but are separated in the
1041
+ projection on the principal plane:
1042
+
1043
+ >>> real = [[0.495, 0.502], [0.354, 0.304]]
1044
+ >>> imag = [[0.333, 0.334], [0.301, 0.349]]
1045
+ >>> x, y, transformation_matrix = phasor_to_principal_plane(real, imag)
1046
+ >>> x, y # doctest: +SKIP
1047
+ (array([0.294, 0.262]), array([0.192, 0.242]))
1048
+ >>> transformation_matrix # doctest: +SKIP
1049
+ array([[0.67, 0.33, -0.09, -0.41], [0.52, -0.52, -0.04, 0.44]])
1050
+
1051
+ """
1052
+ re, im = numpy.atleast_2d(real, imag)
1053
+ if re.shape != im.shape:
1054
+ raise ValueError(f'real={re.shape} != imag={im.shape}')
1055
+
1056
+ # reshape to variables in row, observations in column
1057
+ frequencies = re.shape[0]
1058
+ shape = re.shape[1:]
1059
+ re = re.reshape(re.shape[0], -1)
1060
+ im = im.reshape(im.shape[0], -1)
1061
+
1062
+ # vector of multi-frequency phasor coordinates
1063
+ coordinates = numpy.vstack([re, im])
1064
+
1065
+ # vector of centered coordinates
1066
+ center = numpy.nanmean(coordinates, axis=1, keepdims=True)
1067
+ coordinates -= center
1068
+
1069
+ # covariance matrix (scatter matrix would also work)
1070
+ cov = numpy.cov(coordinates, rowvar=True)
1071
+
1072
+ # calculate eigenvectors
1073
+ _, eigvec = numpy.linalg.eigh(cov)
1074
+
1075
+ # projection matrix: two eigenvectors with largest eigenvalues
1076
+ transformation_matrix = eigvec.T[-2:][::-1]
1077
+
1078
+ if reorient:
1079
+ # for single harmonic, this should restore original coordinates.
1080
+
1081
+ # 1. rotate and scale such that projection of [1, 0] lies at [1, 0]
1082
+ x, y = numpy.dot(
1083
+ transformation_matrix,
1084
+ numpy.vstack(([[1.0]] * frequencies, [[0.0]] * frequencies)),
1085
+ )
1086
+ x = x.item()
1087
+ y = y.item()
1088
+ angle = -math.atan2(y, x)
1089
+ if angle < 0:
1090
+ angle += 2.0 * math.pi
1091
+ cos = math.cos(angle)
1092
+ sin = math.sin(angle)
1093
+ transformation_matrix = numpy.dot(
1094
+ [[cos, -sin], [sin, cos]], transformation_matrix
1095
+ )
1096
+ scale_factor = 1.0 / math.hypot(x, y)
1097
+ transformation_matrix = numpy.dot(
1098
+ [[scale_factor, 0], [0, scale_factor]], transformation_matrix
1099
+ )
1100
+
1101
+ # 2. mirror such that projected center lies in same quadrant
1102
+ cs = math.copysign
1103
+ x, y = numpy.dot(transformation_matrix, center)
1104
+ x = x.item()
1105
+ y = y.item()
1106
+ transformation_matrix = numpy.dot(
1107
+ [
1108
+ [-1 if cs(1, x) != cs(1, center[0][0]) else 1, 0],
1109
+ [0, -1 if cs(1, y) != cs(1, center[1][0]) else 1],
1110
+ ],
1111
+ transformation_matrix,
1112
+ )
1113
+
1114
+ # project multi-frequency phasor coordinates onto principal plane
1115
+ coordinates += center
1116
+ coordinates = numpy.dot(transformation_matrix, coordinates)
1117
+
1118
+ return (
1119
+ coordinates[0].reshape(shape), # x coordinates
1120
+ coordinates[1].reshape(shape), # y coordinates
1121
+ transformation_matrix,
1122
+ )
1123
+
1124
+
1125
+ def phasor_filter_median(
1126
+ mean: ArrayLike,
1127
+ real: ArrayLike,
1128
+ imag: ArrayLike,
1129
+ /,
1130
+ *,
1131
+ repeat: int = 1,
1132
+ size: int = 3,
1133
+ skip_axis: int | Sequence[int] | None = None,
1134
+ use_scipy: bool = False,
1135
+ num_threads: int | None = None,
1136
+ **kwargs: Any,
1137
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
1138
+ """Return median-filtered phasor coordinates.
1139
+
1140
+ By default, apply a NaN-aware median filter independently to the real
1141
+ and imaginary components of phasor coordinates once with a kernel size of 3
1142
+ multiplied by the number of dimensions of the input arrays. Return the
1143
+ intensity unchanged.
1144
+
1145
+ Parameters
1146
+ ----------
1147
+ mean : array_like
1148
+ Intensity of phasor coordinates.
1149
+ real : array_like
1150
+ Real component of phasor coordinates to be filtered.
1151
+ imag : array_like
1152
+ Imaginary component of phasor coordinates to be filtered.
1153
+ repeat : int, optional
1154
+ Number of times to apply median filter. The default is 1.
1155
+ size : int, optional
1156
+ Size of median filter kernel. The default is 3.
1157
+ skip_axis : int or sequence of int, optional
1158
+ Axes in `mean` to exclude from filter.
1159
+ By default, all axes except harmonics are included.
1160
+ use_scipy : bool, optional
1161
+ Use :py:func:`scipy.ndimage.median_filter`.
1162
+ This function has undefined behavior if the input arrays contain
1163
+ NaN values but is faster when filtering more than 2 dimensions.
1164
+ See `issue #87 <https://github.com/phasorpy/phasorpy/issues/87>`_.
1165
+ num_threads : int, optional
1166
+ Number of OpenMP threads to use for parallelization.
1167
+ Applies to filtering in two dimensions when not using scipy.
1168
+ By default, multi-threading is disabled.
1169
+ If zero, up to half of logical CPUs are used.
1170
+ OpenMP may not be available on all platforms.
1171
+ **kwargs
1172
+ Optional arguments passed to :py:func:`scipy.ndimage.median_filter`.
1173
+
1174
+ Returns
1175
+ -------
1176
+ mean : ndarray
1177
+ Unchanged intensity of phasor coordinates.
1178
+ real : ndarray
1179
+ Filtered real component of phasor coordinates.
1180
+ imag : ndarray
1181
+ Filtered imaginary component of phasor coordinates.
1182
+
1183
+ Raises
1184
+ ------
1185
+ ValueError
1186
+ If `repeat` is less than 0.
1187
+ If `size` is less than 1.
1188
+ The array shapes of `mean`, `real`, and `imag` do not match.
1189
+
1190
+ Examples
1191
+ --------
1192
+ Apply three times a median filter with a kernel size of three:
1193
+
1194
+ >>> mean, real, imag = phasor_filter_median(
1195
+ ... [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
1196
+ ... [[0.0, 0.0, 0.0], [0.5, 0.5, 0.5], [0.2, 0.2, 0.2]],
1197
+ ... [[0.3, 0.3, 0.3], [0.6, math.nan, 0.6], [0.4, 0.4, 0.4]],
1198
+ ... size=3,
1199
+ ... repeat=3,
1200
+ ... )
1201
+ >>> mean
1202
+ array([[1, 2, 3],
1203
+ [4, 5, 6],
1204
+ [7, 8, 9]])
1205
+ >>> real
1206
+ array([[0, 0, 0],
1207
+ [0.2, 0.2, 0.2],
1208
+ [0.2, 0.2, 0.2]])
1209
+ >>> imag
1210
+ array([[0.3, 0.3, 0.3],
1211
+ [0.4, nan, 0.4],
1212
+ [0.4, 0.4, 0.4]])
1213
+
1214
+ """
1215
+ if repeat < 0:
1216
+ raise ValueError(f'{repeat=} < 0')
1217
+ if size < 1:
1218
+ raise ValueError(f'{size=} < 1')
1219
+ if size == 1:
1220
+ # no need to filter
1221
+ repeat = 0
1222
+
1223
+ mean = numpy.asarray(mean)
1224
+ if use_scipy or repeat == 0: # or using nD numpy filter
1225
+ real = numpy.asarray(real)
1226
+ elif isinstance(real, numpy.ndarray) and real.dtype == numpy.float32:
1227
+ real = real.copy()
1228
+ else:
1229
+ real = numpy.array(real, numpy.float64, copy=True)
1230
+ if use_scipy or repeat == 0: # or using nD numpy filter
1231
+ imag = numpy.asarray(imag)
1232
+ elif isinstance(imag, numpy.ndarray) and imag.dtype == numpy.float32:
1233
+ imag = imag.copy()
1234
+ else:
1235
+ imag = numpy.array(imag, numpy.float64, copy=True)
1236
+
1237
+ if mean.shape != real.shape[-mean.ndim if mean.ndim else 1 :]:
1238
+ raise ValueError(f'{mean.shape=} != {real.shape=}')
1239
+ if real.shape != imag.shape:
1240
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
1241
+
1242
+ prepend_axis = mean.ndim + 1 == real.ndim
1243
+ _, axes = parse_skip_axis(skip_axis, mean.ndim, prepend_axis)
1244
+
1245
+ # in case mean is also filtered
1246
+ # if prepend_axis:
1247
+ # mean = numpy.expand_dims(mean, axis=0)
1248
+ # ...
1249
+ # if prepend_axis:
1250
+ # mean = numpy.asarray(mean[0])
1251
+
1252
+ if repeat == 0:
1253
+ # no need to call filter
1254
+ return mean, real, imag
1255
+
1256
+ if use_scipy:
1257
+ # use scipy NaN-unaware fallback
1258
+ from scipy.ndimage import median_filter
1259
+
1260
+ kwargs.pop('axes', None)
1261
+
1262
+ for _ in range(repeat):
1263
+ real = median_filter(real, size=size, axes=axes, **kwargs)
1264
+ imag = median_filter(imag, size=size, axes=axes, **kwargs)
1265
+
1266
+ return mean, numpy.asarray(real), numpy.asarray(imag)
1267
+
1268
+ if len(axes) != 2:
1269
+ # n-dimensional median filter using numpy
1270
+ from numpy.lib.stride_tricks import sliding_window_view
1271
+
1272
+ kernel_shape = tuple(
1273
+ size if i in axes else 1 for i in range(real.ndim)
1274
+ )
1275
+ pad_width = [
1276
+ (s // 2, s // 2) if s > 1 else (0, 0) for s in kernel_shape
1277
+ ]
1278
+ axis = tuple(range(-real.ndim, 0))
1279
+
1280
+ nan_mask = numpy.isnan(real)
1281
+ for _ in range(repeat):
1282
+ real = numpy.pad(real, pad_width, mode='edge')
1283
+ real = sliding_window_view(real, kernel_shape)
1284
+ real = numpy.nanmedian(real, axis=axis)
1285
+ real = numpy.where(nan_mask, numpy.nan, real)
1286
+
1287
+ nan_mask = numpy.isnan(imag)
1288
+ for _ in range(repeat):
1289
+ imag = numpy.pad(imag, pad_width, mode='edge')
1290
+ imag = sliding_window_view(imag, kernel_shape)
1291
+ imag = numpy.nanmedian(imag, axis=axis)
1292
+ imag = numpy.where(nan_mask, numpy.nan, imag)
1293
+
1294
+ return mean, real, imag
1295
+
1296
+ # 2-dimensional median filter using optimized Cython implementation
1297
+ num_threads = number_threads(num_threads)
1298
+
1299
+ buffer = numpy.empty(
1300
+ tuple(real.shape[axis] for axis in axes), dtype=real.dtype
1301
+ )
1302
+
1303
+ for index in numpy.ndindex(
1304
+ *[real.shape[ax] for ax in range(real.ndim) if ax not in axes]
1305
+ ):
1306
+ index_list: list[int | slice] = list(index)
1307
+ for ax in axes:
1308
+ index_list = index_list[:ax] + [slice(None)] + index_list[ax:]
1309
+ full_index = tuple(index_list)
1310
+
1311
+ _median_filter_2d(real[full_index], buffer, size, repeat, num_threads)
1312
+ _median_filter_2d(imag[full_index], buffer, size, repeat, num_threads)
1313
+
1314
+ return mean, real, imag
1315
+
1316
+
1317
+ def phasor_filter_pawflim(
1318
+ mean: ArrayLike,
1319
+ real: ArrayLike,
1320
+ imag: ArrayLike,
1321
+ /,
1322
+ *,
1323
+ sigma: float = 2.0,
1324
+ levels: int = 1,
1325
+ harmonic: Sequence[int] | None = None,
1326
+ skip_axis: int | Sequence[int] | None = None,
1327
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
1328
+ """Return pawFLIM wavelet-filtered phasor coordinates.
1329
+
1330
+ This function must only be used with calibrated, unprocessed phasor
1331
+ coordinates obtained from FLIM data. The coordinates must not be filtered,
1332
+ thresholded, or otherwise pre-processed.
1333
+
1334
+ The pawFLIM wavelet filter is described in [2]_.
1335
+
1336
+ Parameters
1337
+ ----------
1338
+ mean : array_like
1339
+ Intensity of phasor coordinates.
1340
+ real : array_like
1341
+ Real component of phasor coordinates to be filtered.
1342
+ Must have at least two harmonics in the first axis.
1343
+ imag : array_like
1344
+ Imaginary component of phasor coordinates to be filtered.
1345
+ Must have at least two harmonics in the first axis.
1346
+ sigma : float, optional
1347
+ Significance level to test difference between two phasors.
1348
+ Given in terms of the equivalent 1D standard deviations.
1349
+ sigma=2 corresponds to ~95% (or 5%) significance.
1350
+ levels : int, optional
1351
+ Number of levels for wavelet decomposition.
1352
+ Controls the maximum averaging area, which has a length of
1353
+ :math:`2^level`.
1354
+ harmonic : sequence of int or None, optional
1355
+ Harmonics included in first axis of `real` and `imag`.
1356
+ If None (default), the first axis of `real` and `imag` contains lower
1357
+ harmonics starting at and increasing by one.
1358
+ All harmonics must have a corresponding half or double harmonic.
1359
+ skip_axis : int or sequence of int, optional
1360
+ Axes in `mean` to exclude from filter.
1361
+ By default, all axes except harmonics are included.
1362
+
1363
+ Returns
1364
+ -------
1365
+ mean : ndarray
1366
+ Unchanged intensity of phasor coordinates.
1367
+ real : ndarray
1368
+ Filtered real component of phasor coordinates.
1369
+ imag : ndarray
1370
+ Filtered imaginary component of phasor coordinates.
1371
+
1372
+ Raises
1373
+ ------
1374
+ ValueError
1375
+ If `level` is less than 0.
1376
+ The array shapes of `mean`, `real`, and `imag` do not match.
1377
+ If `real` and `imag` have no harmonic axis.
1378
+ Number of harmonics in `harmonic` is less than 2 or does not match
1379
+ the first axis of `real` and `imag`.
1380
+ Not all harmonics in `harmonic` have a corresponding half
1381
+ or double harmonic.
1382
+
1383
+ References
1384
+ ----------
1385
+ .. [2] Silberberg M, and Grecco H. `pawFLIM: reducing bias and
1386
+ uncertainty to enable lower photon count in FLIM experiments
1387
+ <https://doi.org/10.1088/2050-6120/aa72ab>`_.
1388
+ *Methods Appl Fluoresc*, 5(2): 024016 (2017)
1389
+
1390
+ Examples
1391
+ --------
1392
+ Apply a pawFLIM wavelet filter with four significance levels (sigma)
1393
+ and three decomposition levels:
1394
+
1395
+ >>> mean, real, imag = phasor_filter_pawflim(
1396
+ ... [[1, 1], [1, 1]],
1397
+ ... [[[0.5, 0.8], [0.5, 0.8]], [[0.2, 0.4], [0.2, 0.4]]],
1398
+ ... [[[0.5, 0.4], [0.5, 0.4]], [[0.4, 0.5], [0.4, 0.5]]],
1399
+ ... sigma=4,
1400
+ ... levels=3,
1401
+ ... harmonic=[1, 2],
1402
+ ... )
1403
+ >>> mean
1404
+ array([[1, 1],
1405
+ [1, 1]])
1406
+ >>> real
1407
+ array([[[0.65, 0.65],
1408
+ [0.65, 0.65]],
1409
+ [[0.3, 0.3],
1410
+ [0.3, 0.3]]])
1411
+ >>> imag
1412
+ array([[[0.45, 0.45],
1413
+ [0.45, 0.45]],
1414
+ [[0.45, 0.45],
1415
+ [0.45, 0.45]]])
1416
+
1417
+ """
1418
+ from pawflim import pawflim # type: ignore[import-untyped]
1419
+
1420
+ mean = numpy.asarray(mean)
1421
+ real = numpy.asarray(real)
1422
+ imag = numpy.asarray(imag)
1423
+
1424
+ if levels < 0:
1425
+ raise ValueError(f'{levels=} < 0')
1426
+ if levels == 0:
1427
+ return mean, real, imag
1428
+
1429
+ if mean.shape != real.shape[-mean.ndim if mean.ndim else 1 :]:
1430
+ raise ValueError(f'{mean.shape=} != {real.shape=}')
1431
+ if real.shape != imag.shape:
1432
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
1433
+
1434
+ has_harmonic_axis = mean.ndim + 1 == real.ndim
1435
+ if not has_harmonic_axis:
1436
+ raise ValueError('no harmonic axis')
1437
+ if harmonic is None:
1438
+ harmonics, _ = parse_harmonic('all', real.shape[0])
1439
+ else:
1440
+ harmonics, _ = parse_harmonic(harmonic, None)
1441
+ if len(harmonics) < 2:
1442
+ raise ValueError(
1443
+ 'at least two harmonics required, ' f'got {len(harmonics)}'
1444
+ )
1445
+ if len(harmonics) != real.shape[0]:
1446
+ raise ValueError(
1447
+ 'number of harmonics does not match first axis of real and imag'
1448
+ )
1449
+
1450
+ mean = numpy.asarray(numpy.nan_to_num(mean), dtype=float)
1451
+ real = numpy.asarray(numpy.nan_to_num(real * mean), dtype=float)
1452
+ imag = numpy.asarray(numpy.nan_to_num(imag * mean), dtype=float)
1453
+
1454
+ mean_expanded = numpy.broadcast_to(mean, real.shape).copy()
1455
+ original_mean_expanded = mean_expanded.copy()
1456
+ real_filtered = real.copy()
1457
+ imag_filtered = imag.copy()
1458
+
1459
+ _, axes = parse_skip_axis(skip_axis, mean.ndim, True)
1460
+
1461
+ for index in numpy.ndindex(
1462
+ *(
1463
+ real.shape[ax]
1464
+ for ax in range(real.ndim)
1465
+ if ax not in axes and ax != 0
1466
+ )
1467
+ ):
1468
+ index_list: list[int | slice] = list(index)
1469
+ for ax in axes:
1470
+ index_list = index_list[:ax] + [slice(None)] + index_list[ax:]
1471
+ full_index = tuple(index_list)
1472
+
1473
+ processed_harmonics = set()
1474
+
1475
+ for h in harmonics:
1476
+ if h in processed_harmonics and (
1477
+ h * 4 in harmonics or h * 2 not in harmonics
1478
+ ):
1479
+ continue
1480
+ if h * 2 not in harmonics:
1481
+ raise ValueError(
1482
+ f'harmonic {h} does not have a corresponding half '
1483
+ f'or double harmonic in {harmonics}'
1484
+ )
1485
+ n = harmonics.index(h)
1486
+ n2 = harmonics.index(h * 2)
1487
+
1488
+ complex_phasor = numpy.empty(
1489
+ (3, *original_mean_expanded[n][full_index].shape),
1490
+ dtype=complex,
1491
+ )
1492
+ complex_phasor[0] = original_mean_expanded[n][full_index]
1493
+ complex_phasor[1] = real[n][full_index] + 1j * imag[n][full_index]
1494
+ complex_phasor[2] = (
1495
+ real[n2][full_index] + 1j * imag[n2][full_index]
1496
+ )
1497
+
1498
+ complex_phasor = pawflim(
1499
+ complex_phasor, n_sigmas=sigma, levels=levels
1500
+ )
1501
+
1502
+ for i, idx in enumerate([n, n2]):
1503
+ if harmonics[idx] in processed_harmonics:
1504
+ continue
1505
+ mean_expanded[idx][full_index] = complex_phasor[0].real
1506
+ real_filtered[idx][full_index] = complex_phasor[i + 1].real
1507
+ imag_filtered[idx][full_index] = complex_phasor[i + 1].imag
1508
+
1509
+ processed_harmonics.add(h)
1510
+ processed_harmonics.add(h * 2)
1511
+
1512
+ with numpy.errstate(divide='ignore', invalid='ignore'):
1513
+ real = numpy.asarray(numpy.divide(real_filtered, mean_expanded))
1514
+ imag = numpy.asarray(numpy.divide(imag_filtered, mean_expanded))
1515
+
1516
+ return mean, real, imag
1517
+
1518
+
1519
+ def phasor_threshold(
1520
+ mean: ArrayLike,
1521
+ real: ArrayLike,
1522
+ imag: ArrayLike,
1523
+ /,
1524
+ mean_min: ArrayLike | None = None,
1525
+ mean_max: ArrayLike | None = None,
1526
+ *,
1527
+ real_min: ArrayLike | None = None,
1528
+ real_max: ArrayLike | None = None,
1529
+ imag_min: ArrayLike | None = None,
1530
+ imag_max: ArrayLike | None = None,
1531
+ phase_min: ArrayLike | None = None,
1532
+ phase_max: ArrayLike | None = None,
1533
+ modulation_min: ArrayLike | None = None,
1534
+ modulation_max: ArrayLike | None = None,
1535
+ open_interval: bool = False,
1536
+ detect_harmonics: bool = True,
1537
+ **kwargs: Any,
1538
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
1539
+ """Return phasor coordinates with values outside interval replaced by NaN.
1540
+
1541
+ Interval thresholds can be set for mean intensity, real and imaginary
1542
+ coordinates, and phase and modulation.
1543
+ Phasor coordinates smaller than minimum thresholds or larger than maximum
1544
+ thresholds are replaced with NaN.
1545
+ No threshold is applied by default.
1546
+ NaNs in `mean` or any `real` and `imag` harmonic are propagated to
1547
+ `mean` and all harmonics in `real` and `imag`.
1548
+
1549
+ Parameters
1550
+ ----------
1551
+ mean : array_like
1552
+ Intensity of phasor coordinates.
1553
+ real : array_like
1554
+ Real component of phasor coordinates.
1555
+ imag : array_like
1556
+ Imaginary component of phasor coordinates.
1557
+ mean_min : array_like, optional
1558
+ Lower threshold for mean intensity.
1559
+ mean_max : array_like, optional
1560
+ Upper threshold for mean intensity.
1561
+ real_min : array_like, optional
1562
+ Lower threshold for real coordinates.
1563
+ real_max : array_like, optional
1564
+ Upper threshold for real coordinates.
1565
+ imag_min : array_like, optional
1566
+ Lower threshold for imaginary coordinates.
1567
+ imag_max : array_like, optional
1568
+ Upper threshold for imaginary coordinates.
1569
+ phase_min : array_like, optional
1570
+ Lower threshold for phase angle.
1571
+ phase_max : array_like, optional
1572
+ Upper threshold for phase angle.
1573
+ modulation_min : array_like, optional
1574
+ Lower threshold for modulation.
1575
+ modulation_max : array_like, optional
1576
+ Upper threshold for modulation.
1577
+ open_interval : bool, optional
1578
+ If true, the interval is open, and the threshold values are
1579
+ not included in the interval.
1580
+ If false (default), the interval is closed, and the threshold values
1581
+ are included in the interval.
1582
+ detect_harmonics : bool, optional
1583
+ By default, detect presence of multiple harmonics from array shapes.
1584
+ If false, no harmonics are assumed to be present, and the function
1585
+ behaves like a numpy universal function.
1586
+ **kwargs
1587
+ Optional `arguments passed to numpy universal functions
1588
+ <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
1589
+
1590
+ Returns
1591
+ -------
1592
+ mean : ndarray
1593
+ Thresholded intensity of phasor coordinates.
1594
+ real : ndarray
1595
+ Thresholded real component of phasor coordinates.
1596
+ imag : ndarray
1597
+ Thresholded imaginary component of phasor coordinates.
1598
+
1599
+ Examples
1600
+ --------
1601
+ Set phasor coordinates to NaN if mean intensity is smaller than 1.1:
1602
+
1603
+ >>> phasor_threshold([1, 2, 3], [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], 1.1)
1604
+ (array([nan, 2, 3]), array([nan, 0.2, 0.3]), array([nan, 0.5, 0.6]))
1605
+
1606
+ Set phasor coordinates to NaN if real component is smaller than 0.15 or
1607
+ larger than 0.25:
1608
+
1609
+ >>> phasor_threshold(
1610
+ ... [1.0, 2.0, 3.0],
1611
+ ... [0.1, 0.2, 0.3],
1612
+ ... [0.4, 0.5, 0.6],
1613
+ ... real_min=0.15,
1614
+ ... real_max=0.25,
1615
+ ... )
1616
+ (array([nan, 2, nan]), array([nan, 0.2, nan]), array([nan, 0.5, nan]))
1617
+
1618
+ Apply NaNs to other input arrays:
1619
+
1620
+ >>> phasor_threshold(
1621
+ ... [numpy.nan, 2, 3], [0.1, 0.2, 0.3], [0.4, 0.5, numpy.nan]
1622
+ ... )
1623
+ (array([nan, 2, nan]), array([nan, 0.2, nan]), array([nan, 0.5, nan]))
1624
+
1625
+ """
1626
+ threshold_mean_only = None
1627
+ if mean_min is None:
1628
+ mean_min = numpy.nan
1629
+ else:
1630
+ threshold_mean_only = True
1631
+ if mean_max is None:
1632
+ mean_max = numpy.nan
1633
+ else:
1634
+ threshold_mean_only = True
1635
+ if real_min is None:
1636
+ real_min = numpy.nan
1637
+ else:
1638
+ threshold_mean_only = False
1639
+ if real_max is None:
1640
+ real_max = numpy.nan
1641
+ else:
1642
+ threshold_mean_only = False
1643
+ if imag_min is None:
1644
+ imag_min = numpy.nan
1645
+ else:
1646
+ threshold_mean_only = False
1647
+ if imag_max is None:
1648
+ imag_max = numpy.nan
1649
+ else:
1650
+ threshold_mean_only = False
1651
+ if phase_min is None:
1652
+ phase_min = numpy.nan
1653
+ else:
1654
+ threshold_mean_only = False
1655
+ if phase_max is None:
1656
+ phase_max = numpy.nan
1657
+ else:
1658
+ threshold_mean_only = False
1659
+ if modulation_min is None:
1660
+ modulation_min = numpy.nan
1661
+ else:
1662
+ threshold_mean_only = False
1663
+ if modulation_max is None:
1664
+ modulation_max = numpy.nan
1665
+ else:
1666
+ threshold_mean_only = False
1667
+
1668
+ if detect_harmonics:
1669
+ mean = numpy.asarray(mean)
1670
+ real = numpy.asarray(real)
1671
+ imag = numpy.asarray(imag)
1672
+
1673
+ shape = numpy.broadcast_shapes(mean.shape, real.shape, imag.shape)
1674
+ ndim = len(shape)
1675
+
1676
+ has_harmonic_axis = (
1677
+ # detect multi-harmonic in axis 0
1678
+ mean.ndim + 1 == ndim
1679
+ and real.shape == shape
1680
+ and imag.shape == shape
1681
+ and mean.shape == shape[-mean.ndim if mean.ndim else 1 :]
1682
+ )
1683
+ else:
1684
+ has_harmonic_axis = False
1685
+
1686
+ if threshold_mean_only is None:
1687
+ mean, real, imag = _phasor_threshold_nan(mean, real, imag, **kwargs)
1688
+
1689
+ elif threshold_mean_only:
1690
+ mean_func = (
1691
+ _phasor_threshold_mean_open
1692
+ if open_interval
1693
+ else _phasor_threshold_mean_closed
1694
+ )
1695
+ mean, real, imag = mean_func(
1696
+ mean, real, imag, mean_min, mean_max, **kwargs
1697
+ )
1698
+
1699
+ else:
1700
+ func = (
1701
+ _phasor_threshold_open
1702
+ if open_interval
1703
+ else _phasor_threshold_closed
1704
+ )
1705
+ mean, real, imag = func(
1706
+ mean,
1707
+ real,
1708
+ imag,
1709
+ mean_min,
1710
+ mean_max,
1711
+ real_min,
1712
+ real_max,
1713
+ imag_min,
1714
+ imag_max,
1715
+ phase_min,
1716
+ phase_max,
1717
+ modulation_min,
1718
+ modulation_max,
1719
+ **kwargs,
1720
+ )
1721
+
1722
+ mean = numpy.asarray(mean)
1723
+ real = numpy.asarray(real)
1724
+ imag = numpy.asarray(imag)
1725
+ if has_harmonic_axis and mean.ndim > 0:
1726
+ # propagate NaN to all dimensions
1727
+ mean = numpy.mean(mean, axis=0, keepdims=True)
1728
+ mask = numpy.where(numpy.isnan(mean), numpy.nan, 1.0)
1729
+ numpy.multiply(real, mask, out=real)
1730
+ numpy.multiply(imag, mask, out=imag)
1731
+ # remove harmonic dimension created by broadcasting
1732
+ mean = numpy.asarray(numpy.asarray(mean)[0])
1733
+
1734
+ return mean, real, imag
1735
+
1736
+
1737
+ def phasor_nearest_neighbor(
1738
+ real: ArrayLike,
1739
+ imag: ArrayLike,
1740
+ neighbor_real: ArrayLike,
1741
+ neighbor_imag: ArrayLike,
1742
+ /,
1743
+ *,
1744
+ values: ArrayLike | None = None,
1745
+ dtype: DTypeLike | None = None,
1746
+ distance_max: float | None = None,
1747
+ num_threads: int | None = None,
1748
+ ) -> NDArray[Any]:
1749
+ """Return indices or values of nearest neighbors from other coordinates.
1750
+
1751
+ For each phasor coordinate, find the nearest neighbor in another set of
1752
+ phasor coordinates and return its flat index. If more than one neighbor
1753
+ has the same distance, return the smallest index.
1754
+
1755
+ For phasor coordinates that are NaN, or have a distance to the nearest
1756
+ neighbor that is larger than `distance_max`, return an index of -1.
1757
+
1758
+ If `values` are provided, return the values corresponding to the nearest
1759
+ neighbor coordinates instead of indices. Return NaN values for indices
1760
+ that are -1.
1761
+
1762
+ This function does not support multi-harmonic, multi-channel, or
1763
+ multi-frequency phasor coordinates.
1764
+
1765
+ Parameters
1766
+ ----------
1767
+ real : array_like
1768
+ Real component of phasor coordinates.
1769
+ imag : array_like
1770
+ Imaginary component of phasor coordinates.
1771
+ neighbor_real : array_like
1772
+ Real component of neighbor phasor coordinates.
1773
+ neighbor_imag : array_like
1774
+ Imaginary component of neighbor phasor coordinates.
1775
+ values : array_like, optional
1776
+ Array of values corresponding to neighbor coordinates.
1777
+ If provided, return the values corresponding to the nearest
1778
+ neighbor coordinates.
1779
+ distance_max : float, optional
1780
+ Maximum Euclidean distance to consider a neighbor valid.
1781
+ By default, all neighbors are considered.
1782
+ dtype : dtype_like, optional
1783
+ Floating point data type used for calculation and output values.
1784
+ Either `float32` or `float64`. The default is `float64`.
1785
+ num_threads : int, optional
1786
+ Number of OpenMP threads to use for parallelization.
1787
+ By default, multi-threading is disabled.
1788
+ If zero, up to half of logical CPUs are used.
1789
+ OpenMP may not be available on all platforms.
1790
+
1791
+ Returns
1792
+ -------
1793
+ nearest : ndarray
1794
+ Flat indices (or the corresponding values if provided) of the nearest
1795
+ neighbor coordinates.
1796
+
1797
+ Raises
1798
+ ------
1799
+ ValueError
1800
+ If the shapes of `real`, and `imag` do not match.
1801
+ If the shapes of `neighbor_real` and `neighbor_imag` do not match.
1802
+ If the shapes of `values` and `neighbor_real` do not match.
1803
+ If `distance_max` is less than or equal to zero.
1804
+
1805
+ See Also
1806
+ --------
1807
+ :ref:`sphx_glr_tutorials_applications_phasorpy_fret_efficiency.py`
1808
+
1809
+ Notes
1810
+ -----
1811
+ This function uses linear search, which is inefficient for large
1812
+ number of coordinates or neighbors.
1813
+ ``scipy.spatial.KDTree.query()`` would be more efficient in those cases.
1814
+ However, KDTree is known to return non-deterministic results in case of
1815
+ multiple neighbors with the same distance.
1816
+
1817
+ Examples
1818
+ --------
1819
+ >>> phasor_nearest_neighbor(
1820
+ ... [0.1, 0.5, numpy.nan],
1821
+ ... [0.1, 0.5, numpy.nan],
1822
+ ... [0, 0.4],
1823
+ ... [0, 0.4],
1824
+ ... values=[10, 20],
1825
+ ... )
1826
+ array([10, 20, nan])
1827
+
1828
+ """
1829
+ dtype = numpy.dtype(dtype)
1830
+ if dtype.char not in {'f', 'd'}:
1831
+ raise ValueError(f'{dtype=} is not a floating point type')
1832
+
1833
+ real = numpy.ascontiguousarray(real, dtype=dtype)
1834
+ imag = numpy.ascontiguousarray(imag, dtype=dtype)
1835
+ neighbor_real = numpy.ascontiguousarray(neighbor_real, dtype=dtype)
1836
+ neighbor_imag = numpy.ascontiguousarray(neighbor_imag, dtype=dtype)
1837
+
1838
+ if real.shape != imag.shape:
1839
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
1840
+ if neighbor_real.shape != neighbor_imag.shape:
1841
+ raise ValueError(f'{neighbor_real.shape=} != {neighbor_imag.shape=}')
1842
+
1843
+ shape = real.shape
1844
+ real = real.ravel()
1845
+ imag = imag.ravel()
1846
+ neighbor_real = neighbor_real.ravel()
1847
+ neighbor_imag = neighbor_imag.ravel()
1848
+
1849
+ indices = numpy.empty(
1850
+ real.shape, numpy.min_scalar_type(-neighbor_real.size)
1851
+ )
1852
+
1853
+ if distance_max is None:
1854
+ distance_max = numpy.inf
1855
+ else:
1856
+ distance_max = float(distance_max)
1857
+ if distance_max <= 0:
1858
+ raise ValueError(f'{distance_max=} <= 0')
1859
+
1860
+ num_threads = number_threads(num_threads)
1861
+
1862
+ _nearest_neighbor_2d(
1863
+ indices,
1864
+ real,
1865
+ imag,
1866
+ neighbor_real,
1867
+ neighbor_imag,
1868
+ distance_max,
1869
+ num_threads,
1870
+ )
1871
+
1872
+ if values is None:
1873
+ return numpy.asarray(indices.reshape(shape))
1874
+
1875
+ values = numpy.ascontiguousarray(values, dtype=dtype).ravel()
1876
+ if values.shape != neighbor_real.shape:
1877
+ raise ValueError(f'{values.shape=} != {neighbor_real.shape=}')
1878
+
1879
+ nearest_values = values[indices]
1880
+ nearest_values[indices == -1] = numpy.nan
1881
+
1882
+ return numpy.asarray(nearest_values.reshape(shape))
1883
+
1884
+
1885
+ def phasor_center(
1886
+ mean: ArrayLike,
1887
+ real: ArrayLike,
1888
+ imag: ArrayLike,
1889
+ /,
1890
+ *,
1891
+ skip_axis: int | Sequence[int] | None = None,
1892
+ method: Literal['mean', 'median'] = 'mean',
1893
+ nan_safe: bool = True,
1894
+ **kwargs: Any,
1895
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
1896
+ """Return center of phasor coordinates.
1897
+
1898
+ Parameters
1899
+ ----------
1900
+ mean : array_like
1901
+ Intensity of phasor coordinates.
1902
+ real : array_like
1903
+ Real component of phasor coordinates.
1904
+ imag : array_like
1905
+ Imaginary component of phasor coordinates.
1906
+ skip_axis : int or sequence of int, optional
1907
+ Axes in `mean` to excluded from center calculation.
1908
+ By default, all axes except harmonics are included.
1909
+ method : str, optional
1910
+ Method used for center calculation:
1911
+
1912
+ - ``'mean'``: Arithmetic mean of phasor coordinates.
1913
+ - ``'median'``: Spatial median of phasor coordinates.
1914
+
1915
+ nan_safe : bool, optional
1916
+ Ensure `method` is applied to same elements of input arrays.
1917
+ By default, distribute NaNs among input arrays before applying
1918
+ `method`. May be disabled if phasor coordinates were filtered by
1919
+ :py:func:`phasor_threshold`.
1920
+ **kwargs
1921
+ Optional arguments passed to :py:func:`numpy.nanmean` or
1922
+ :py:func:`numpy.nanmedian`.
1923
+
1924
+ Returns
1925
+ -------
1926
+ mean_center : ndarray
1927
+ Intensity center coordinates.
1928
+ real_center : ndarray
1929
+ Real center coordinates.
1930
+ imag_center : ndarray
1931
+ Imaginary center coordinates.
1932
+
1933
+ Raises
1934
+ ------
1935
+ ValueError
1936
+ If the specified method is not supported.
1937
+ If the shapes of `mean`, `real`, and `imag` do not match.
1938
+
1939
+ Examples
1940
+ --------
1941
+ Compute center coordinates with the default 'mean' method:
1942
+
1943
+ >>> phasor_center(
1944
+ ... [2, 1, 2], [0.1, 0.2, 0.3], [0.4, 0.5, 0.6]
1945
+ ... ) # doctest: +NUMBER
1946
+ (1.67, 0.2, 0.5)
1947
+
1948
+ Compute center coordinates with the 'median' method:
1949
+
1950
+ >>> phasor_center(
1951
+ ... [1, 2, 3], [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], method='median'
1952
+ ... )
1953
+ (2.0, 0.2, 0.5)
1954
+
1955
+ """
1956
+ methods = {
1957
+ 'mean': _mean,
1958
+ 'median': _median,
1959
+ }
1960
+ if method not in methods:
1961
+ raise ValueError(
1962
+ f'Method not supported, supported methods are: '
1963
+ f"{', '.join(methods)}"
1964
+ )
1965
+
1966
+ mean = numpy.asarray(mean)
1967
+ real = numpy.asarray(real)
1968
+ imag = numpy.asarray(imag)
1969
+ if real.shape != imag.shape:
1970
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
1971
+ if mean.shape != real.shape[-mean.ndim if mean.ndim else 1 :]:
1972
+ raise ValueError(f'{mean.shape=} != {real.shape=}')
1973
+
1974
+ prepend_axis = mean.ndim + 1 == real.ndim
1975
+ _, axis = parse_skip_axis(skip_axis, mean.ndim, prepend_axis)
1976
+ if prepend_axis:
1977
+ mean = numpy.expand_dims(mean, axis=0)
1978
+
1979
+ if nan_safe:
1980
+ mean, real, imag = phasor_threshold(mean, real, imag)
1981
+
1982
+ mean, real, imag = methods[method](mean, real, imag, axis=axis, **kwargs)
1983
+
1984
+ if prepend_axis:
1985
+ mean = numpy.asarray(mean[0])
1986
+ return mean, real, imag
1987
+
1988
+
1989
+ def _mean(
1990
+ mean: NDArray[Any],
1991
+ real: NDArray[Any],
1992
+ imag: NDArray[Any],
1993
+ /,
1994
+ **kwargs: Any,
1995
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
1996
+ """Return mean center of phasor coordinates."""
1997
+ real = numpy.nanmean(real * mean, **kwargs)
1998
+ imag = numpy.nanmean(imag * mean, **kwargs)
1999
+ mean = numpy.nanmean(mean, **kwargs)
2000
+ with numpy.errstate(divide='ignore', invalid='ignore'):
2001
+ real /= mean
2002
+ imag /= mean
2003
+ return mean, real, imag
2004
+
2005
+
2006
+ def _median(
2007
+ mean: NDArray[Any],
2008
+ real: NDArray[Any],
2009
+ imag: NDArray[Any],
2010
+ /,
2011
+ **kwargs: Any,
2012
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
2013
+ """Return spatial median center of phasor coordinates."""
2014
+ return (
2015
+ numpy.nanmedian(mean, **kwargs),
2016
+ numpy.nanmedian(real, **kwargs),
2017
+ numpy.nanmedian(imag, **kwargs),
2018
+ )