phasorpy 0.7__cp314-cp314-win_arm64.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/__init__.py +9 -0
- phasorpy/__main__.py +7 -0
- phasorpy/_phasorpy.cp314-win_arm64.pyd +0 -0
- phasorpy/_phasorpy.pyx +2688 -0
- phasorpy/_typing.py +77 -0
- phasorpy/_utils.py +786 -0
- phasorpy/cli.py +160 -0
- phasorpy/cluster.py +200 -0
- phasorpy/color.py +589 -0
- phasorpy/component.py +707 -0
- phasorpy/conftest.py +38 -0
- phasorpy/cursor.py +500 -0
- phasorpy/datasets.py +722 -0
- phasorpy/experimental.py +310 -0
- phasorpy/io/__init__.py +138 -0
- phasorpy/io/_flimlabs.py +360 -0
- phasorpy/io/_leica.py +331 -0
- phasorpy/io/_ometiff.py +444 -0
- phasorpy/io/_other.py +890 -0
- phasorpy/io/_simfcs.py +652 -0
- phasorpy/lifetime.py +2058 -0
- phasorpy/phasor.py +2018 -0
- phasorpy/plot/__init__.py +27 -0
- phasorpy/plot/_functions.py +723 -0
- phasorpy/plot/_lifetime_plots.py +563 -0
- phasorpy/plot/_phasorplot.py +1507 -0
- phasorpy/plot/_phasorplot_fret.py +561 -0
- phasorpy/py.typed +0 -0
- phasorpy/utils.py +172 -0
- phasorpy-0.7.dist-info/METADATA +74 -0
- phasorpy-0.7.dist-info/RECORD +35 -0
- phasorpy-0.7.dist-info/WHEEL +5 -0
- phasorpy-0.7.dist-info/entry_points.txt +2 -0
- phasorpy-0.7.dist-info/licenses/LICENSE.txt +21 -0
- phasorpy-0.7.dist-info/top_level.txt +1 -0
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
|
+
)
|