phasorpy 0.1__cp313-cp313-win_amd64.whl → 0.3__cp313-cp313-win_amd64.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 CHANGED
@@ -30,6 +30,7 @@ The ``phasorpy.phasor`` module provides functions to:
30
30
  - :py:func:`phasor_transform`
31
31
  - :py:func:`phasor_multiply`
32
32
  - :py:func:`phasor_divide`
33
+ - :py:func:`phasor_normalize`
33
34
 
34
35
  - calibrate phasor coordinates with reference of known fluorescence
35
36
  lifetime:
@@ -64,7 +65,7 @@ The ``phasorpy.phasor`` module provides functions to:
64
65
 
65
66
  - filter phasor coordinates:
66
67
 
67
- - :py:func:`phasor_filter`
68
+ - :py:func:`phasor_filter_median`
68
69
  - :py:func:`phasor_threshold`
69
70
 
70
71
  """
@@ -81,7 +82,7 @@ __all__ = [
81
82
  'phasor_calibrate',
82
83
  'phasor_center',
83
84
  'phasor_divide',
84
- 'phasor_filter',
85
+ 'phasor_filter_median',
85
86
  'phasor_from_apparent_lifetime',
86
87
  'phasor_from_fret_acceptor',
87
88
  'phasor_from_fret_donor',
@@ -89,6 +90,7 @@ __all__ = [
89
90
  'phasor_from_polar',
90
91
  'phasor_from_signal',
91
92
  'phasor_multiply',
93
+ 'phasor_normalize',
92
94
  'phasor_semicircle',
93
95
  'phasor_threshold',
94
96
  'phasor_to_apparent_lifetime',
@@ -104,7 +106,6 @@ __all__ = [
104
106
  ]
105
107
 
106
108
  import math
107
- import numbers
108
109
  from collections.abc import Sequence
109
110
  from typing import TYPE_CHECKING
110
111
 
@@ -122,6 +123,7 @@ import numpy
122
123
 
123
124
  from ._phasorpy import (
124
125
  _gaussian_signal,
126
+ _median_filter_2d,
125
127
  _phasor_at_harmonic,
126
128
  _phasor_divide,
127
129
  _phasor_from_apparent_lifetime,
@@ -161,6 +163,7 @@ def phasor_from_signal(
161
163
  use_fft: bool | None = None,
162
164
  rfft: Callable[..., NDArray[Any]] | None = None,
163
165
  dtype: DTypeLike = None,
166
+ normalize: bool = True,
164
167
  num_threads: int | None = None,
165
168
  ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
166
169
  r"""Return phasor coordinates from signal.
@@ -180,6 +183,8 @@ def phasor_from_signal(
180
183
  Else, harmonics must be at least one and no larger than half the
181
184
  number of `signal` samples along `axis`.
182
185
  The default is the first harmonic (fundamental frequency).
186
+ A minimum of `harmonic * 2 + 1` samples are required along `axis`
187
+ to calculate correct phasor coordinates at `harmonic`.
183
188
  sample_phase : array_like, optional
184
189
  Phase values (in radians) of `signal` samples along `axis`.
185
190
  If None (default), samples are assumed to be uniformly spaced along
@@ -199,6 +204,14 @@ def phasor_from_signal(
199
204
  dtype : dtype_like, optional
200
205
  Data type of output arrays. Either float32 or float64.
201
206
  The default is float64 unless the `signal` is float32.
207
+ normalize : bool, optional
208
+ Return normalized phasor coordinates.
209
+ If true (default), return average of `signal` along `axis` and
210
+ Fourier coefficients divided by sum of `signal` along `axis`.
211
+ Else, return sum of `signal` along `axis` and unscaled Fourier
212
+ coefficients.
213
+ Un-normalized phasor coordinates cannot be used with most of PhasorPy's
214
+ functions but may be required for intermediate processing.
202
215
  num_threads : int, optional
203
216
  Number of OpenMP threads to use for parallelization when not using FFT.
204
217
  By default, multi-threading is disabled.
@@ -229,13 +242,15 @@ def phasor_from_signal(
229
242
  See Also
230
243
  --------
231
244
  phasorpy.phasor.phasor_to_signal
245
+ phasorpy.phasor.phasor_normalize
232
246
  :ref:`sphx_glr_tutorials_benchmarks_phasorpy_phasor_from_signal.py`
233
247
 
234
248
  Notes
235
249
  -----
236
- The phasor coordinates `real` (:math:`G`), `imag` (:math:`S`), and
237
- `mean` (:math:`F_{DC}`) are calculated from :math:`K\ge3` samples of the
238
- signal :math:`F` af `harmonic` :math:`h` according to:
250
+ The normalized phasor coordinates `real` (:math:`G`), `imag` (:math:`S`),
251
+ and average intensity `mean` (:math:`F_{DC}`) are calculated from
252
+ :math:`K\ge3` samples of the signal :math:`F` af `harmonic` :math:`h`
253
+ according to:
239
254
 
240
255
  .. math::
241
256
 
@@ -285,7 +300,7 @@ def phasor_from_signal(
285
300
  if dtype.kind != 'f':
286
301
  raise TypeError(f'{dtype=} not supported')
287
302
 
288
- harmonic, keepdims = parse_harmonic(harmonic, samples)
303
+ harmonic, keepdims = parse_harmonic(harmonic, samples // 2)
289
304
  num_harmonics = len(harmonic)
290
305
 
291
306
  if sample_phase is not None:
@@ -303,14 +318,16 @@ def phasor_from_signal(
303
318
  use_fft = sample_phase is None and (
304
319
  rfft is not None
305
320
  or num_harmonics > 7
306
- or num_harmonics == samples // 2
321
+ or num_harmonics >= samples // 2
307
322
  )
308
323
 
309
324
  if use_fft:
310
325
  if rfft is None:
311
326
  rfft = numpy.fft.rfft
312
327
 
313
- fft: NDArray[Any] = rfft(signal, axis=axis, norm='forward')
328
+ fft: NDArray[Any] = rfft(
329
+ signal, axis=axis, norm='forward' if normalize else 'backward'
330
+ )
314
331
 
315
332
  mean = fft.take(0, axis=axis).real
316
333
  if not mean.ndim == 0:
@@ -330,10 +347,10 @@ def phasor_from_signal(
330
347
  real = numpy.moveaxis(real, axis, 0)
331
348
  imag = numpy.moveaxis(imag, axis, 0)
332
349
 
333
- # complex division by mean signal
334
- with numpy.errstate(divide='ignore', invalid='ignore'):
335
- real /= dc
336
- imag /= dc
350
+ if normalize:
351
+ with numpy.errstate(divide='ignore', invalid='ignore'):
352
+ real /= dc
353
+ imag /= dc
337
354
  numpy.negative(imag, out=imag)
338
355
 
339
356
  if not keepdims and real.ndim == 0:
@@ -367,7 +384,7 @@ def phasor_from_signal(
367
384
  phasor = numpy.empty((num_harmonics * 2 + 1, size0, size1), dtype)
368
385
  signal = signal.reshape((size0, samples, size1))
369
386
 
370
- _phasor_from_signal(phasor, signal, sincos, num_threads)
387
+ _phasor_from_signal(phasor, signal, sincos, normalize, num_threads)
371
388
 
372
389
  # restore original shape
373
390
  shape = shape0 + shape1
@@ -398,7 +415,7 @@ def phasor_to_signal(
398
415
  ----------
399
416
  mean : array_like
400
417
  Average signal intensity (DC).
401
- If not scalar, shape must match the last two dimensions of `real`.
418
+ If not scalar, shape must match the last dimensions of `real`.
402
419
  real : array_like
403
420
  Real component of phasor coordinates.
404
421
  Multiple harmonics, if any, must be in the first axis.
@@ -413,7 +430,7 @@ def phasor_to_signal(
413
430
  coordinates (most commonly, lower harmonics are present if the number
414
431
  of dimensions of `mean` is one less than `real`).
415
432
  If `'all'`, the harmonics in the first axis of phasor coordinates are
416
- the lower harmonics.
433
+ the lower harmonics necessary to synthesize `samples`.
417
434
  Else, harmonics must be at least one and no larger than half of
418
435
  `samples`.
419
436
  The phasor coordinates of missing harmonics are zeroed
@@ -462,18 +479,15 @@ def phasor_to_signal(
462
479
  array([2.2, 2.486, 0.8566, -0.4365, 0.3938])
463
480
 
464
481
  """
482
+ if samples < 3:
483
+ raise ValueError(f'{samples=} < 3')
484
+
465
485
  mean = numpy.array(mean, ndmin=0, copy=True)
466
486
  real = numpy.array(real, ndmin=0, copy=True)
467
487
  imag = numpy.array(imag, ndmin=1, copy=True)
468
488
 
469
- if isinstance(harmonic, (int, numbers.Integral)) and harmonic == 0:
470
- # harmonics are expected in the first axes of real and imag
471
- samples_ = 2 * imag.shape[0]
472
- else:
473
- samples_ = samples
474
-
475
489
  harmonic_ = harmonic
476
- harmonic, has_harmonic_axis = parse_harmonic(harmonic, samples_)
490
+ harmonic, has_harmonic_axis = parse_harmonic(harmonic, samples // 2)
477
491
 
478
492
  if real.ndim == 1 and len(harmonic) > 1 and real.shape[0] == len(harmonic):
479
493
  # single axis contains harmonic
@@ -508,7 +522,6 @@ def phasor_to_signal(
508
522
  if len(harmonic) != real.shape[0]:
509
523
  raise ValueError(f'{len(harmonic)=} != {real.shape[0]=}')
510
524
 
511
- # complex multiplication by mean signal
512
525
  real *= mean
513
526
  imag *= mean
514
527
  numpy.negative(imag, out=imag)
@@ -641,7 +654,7 @@ def lifetime_to_signal(
641
654
  if harmonic is None:
642
655
  harmonic = 'all'
643
656
  all_hamonics = harmonic == 'all'
644
- harmonic, _ = parse_harmonic(harmonic, samples)
657
+ harmonic, _ = parse_harmonic(harmonic, samples // 2)
645
658
 
646
659
  if samples < 16:
647
660
  raise ValueError(f'{samples=} < 16')
@@ -953,24 +966,119 @@ def phasor_divide(
953
966
  )
954
967
 
955
968
 
969
+ def phasor_normalize(
970
+ mean_unnormalized: ArrayLike,
971
+ real_unnormalized: ArrayLike,
972
+ imag_unnormalized: ArrayLike,
973
+ /,
974
+ samples: int = 1,
975
+ dtype: DTypeLike = None,
976
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
977
+ r"""Return normalized phasor coordinates.
978
+
979
+ Use to normalize the phasor coordinates returned by
980
+ ``phasor_from_signal(..., normalize=False)``.
981
+
982
+ Parameters
983
+ ----------
984
+ mean_unnormalized : array_like
985
+ Unnormalized intensity of phasor coordinates.
986
+ real_unnormalized : array_like
987
+ Unnormalized real component of phasor coordinates.
988
+ imag_unnormalized : array_like
989
+ Unnormalized imaginary component of phasor coordinates.
990
+ samples : int, default: 1
991
+ Number of signal samples over which `mean` was integrated.
992
+ dtype : dtype_like, optional
993
+ Data type of output arrays. Either float32 or float64.
994
+ The default is float64 unless the `real` is float32.
995
+
996
+ Returns
997
+ -------
998
+ mean : ndarray
999
+ Normalized intensity.
1000
+ real : ndarray
1001
+ Normalized real component.
1002
+ imag : ndarray
1003
+ Normalized imaginary component.
1004
+
1005
+ Notes
1006
+ -----
1007
+ The average intensity `mean` (:math:`F_{DC}`) and normalized phasor
1008
+ coordinates `real` (:math:`G`) and `imag` (:math:`S`) are calculated from
1009
+ the signal `intensity` (:math:`F`), the number of `samples` (:math:`K`),
1010
+ `real_unnormalized` (:math:`G'`), and `imag_unnormalized` (:math:`S'`)
1011
+ according to:
1012
+
1013
+ .. math::
1014
+
1015
+ F_{DC} &= F / K
1016
+
1017
+ G &= G' / F
1018
+
1019
+ S &= S' / F
1020
+
1021
+ If :math:`F = 0`, the normalized phasor coordinates (:math:`G`)
1022
+ and (:math:`S`) are undefined (:math:`NaN` or :math:`\infty`).
1023
+
1024
+ Examples
1025
+ --------
1026
+ Normalize phasor coordinates with intensity integrated over 10 samples:
1027
+
1028
+ >>> phasor_normalize([0.0, 0.1], [0.0, 0.3], [0.4, 0.5], samples=10)
1029
+ (array([0, 0.01]), array([nan, 3]), array([inf, 5]))
1030
+
1031
+ Normalize multi-harmonic phasor coordinates:
1032
+
1033
+ >>> phasor_normalize(0.1, [0.0, 0.3], [0.4, 0.5], samples=10)
1034
+ (array(0.01), array([0, 3]), array([4, 5]))
1035
+
1036
+ """
1037
+ if samples < 1:
1038
+ raise ValueError(f'{samples=} < 1')
1039
+
1040
+ if (
1041
+ dtype is None
1042
+ and isinstance(real_unnormalized, numpy.ndarray)
1043
+ and real_unnormalized.dtype == numpy.float32
1044
+ ):
1045
+ real = real_unnormalized.copy()
1046
+ else:
1047
+ real = numpy.array(real_unnormalized, dtype, copy=True)
1048
+ imag = numpy.array(imag_unnormalized, real.dtype, copy=True)
1049
+ mean = numpy.array(
1050
+ mean_unnormalized, real.dtype, copy=None if samples == 1 else True
1051
+ )
1052
+
1053
+ with numpy.errstate(divide='ignore', invalid='ignore'):
1054
+ numpy.divide(real, mean, out=real)
1055
+ numpy.divide(imag, mean, out=imag)
1056
+ if samples > 1:
1057
+ numpy.divide(mean, samples, out=mean)
1058
+
1059
+ return mean, real, imag
1060
+
1061
+
956
1062
  def phasor_calibrate(
957
1063
  real: ArrayLike,
958
1064
  imag: ArrayLike,
1065
+ reference_mean: ArrayLike,
959
1066
  reference_real: ArrayLike,
960
1067
  reference_imag: ArrayLike,
961
1068
  /,
962
1069
  frequency: ArrayLike,
963
1070
  lifetime: ArrayLike,
964
1071
  *,
1072
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
1073
+ skip_axis: int | Sequence[int] | None = None,
965
1074
  fraction: ArrayLike | None = None,
966
1075
  preexponential: bool = False,
967
1076
  unit_conversion: float = 1e-3,
968
- reverse: bool = False,
969
1077
  method: Literal['mean', 'median'] = 'mean',
970
- skip_axis: int | Sequence[int] | None = None,
1078
+ nan_safe: bool = True,
1079
+ reverse: bool = False,
971
1080
  ) -> tuple[NDArray[Any], NDArray[Any]]:
972
- """
973
- Return calibrated/referenced phasor coordinates.
1081
+ """Return calibrated/referenced phasor coordinates.
974
1082
 
975
1083
  Calibration of phasor coordinates from time-resolved measurements is
976
1084
  necessary to account for the instrument response function (IRF) and delays
@@ -982,20 +1090,34 @@ def phasor_calibrate(
982
1090
  Real component of phasor coordinates to be calibrated.
983
1091
  imag : array_like
984
1092
  Imaginary component of phasor coordinates to be calibrated.
1093
+ reference_mean : array_like or None
1094
+ Intensity of phasor coordinates from reference of known lifetime.
1095
+ Used to re-normalize averaged phasor coordinates.
985
1096
  reference_real : array_like
986
1097
  Real component of phasor coordinates from reference of known lifetime.
987
1098
  Must be measured with the same instrument setting as the phasor
988
- coordinates to be calibrated.
1099
+ coordinates to be calibrated. Dimensions must be the same as `real`.
989
1100
  reference_imag : array_like
990
1101
  Imaginary component of phasor coordinates from reference of known
991
1102
  lifetime.
992
1103
  Must be measured with the same instrument setting as the phasor
993
1104
  coordinates to be calibrated.
994
1105
  frequency : array_like
995
- Laser pulse or modulation frequency in MHz.
996
- A scalar or one-dimensional sequence.
1106
+ Fundamental laser pulse or modulation frequency in MHz.
997
1107
  lifetime : array_like
998
- Lifetime components in ns. Must be scalar or one dimensional.
1108
+ Lifetime components in ns. Must be scalar or one-dimensional.
1109
+ harmonic : int, sequence of int, or 'all', default: 1
1110
+ Harmonics included in `real` and `imag`.
1111
+ If an integer, the harmonics at which `real` and `imag` were acquired
1112
+ or calculated.
1113
+ If a sequence, the harmonics included in the first axis of `real` and
1114
+ `imag`.
1115
+ If `'all'`, the first axis of `real` and `imag` contains lower
1116
+ harmonics.
1117
+ The default is the first harmonic (fundamental frequency).
1118
+ skip_axis : int or sequence of int, optional
1119
+ Axes in `reference_mean` to exclude from reference center calculation.
1120
+ By default, all axes except harmonics are included.
999
1121
  fraction : array_like, optional
1000
1122
  Fractional intensities or pre-exponential amplitudes of the lifetime
1001
1123
  components. Fractions are normalized to sum to 1.
@@ -1007,17 +1129,18 @@ def phasor_calibrate(
1007
1129
  Product of `frequency` and `lifetime` units' prefix factors.
1008
1130
  The default is 1e-3 for MHz and ns, or Hz and ms.
1009
1131
  Use 1.0 for Hz and s.
1010
- reverse : bool, optional
1011
- Reverse calibration.
1012
1132
  method : str, optional
1013
- Method used for calculating center of `reference_real` and
1014
- `reference_imag`:
1133
+ Method used for calculating center of reference phasor coordinates:
1015
1134
 
1016
- - ``'mean'``: Arithmetic mean of phasor coordinates.
1017
- - ``'median'``: Spatial median of phasor coordinates.
1018
- skip_axis : int or sequence of int, optional
1019
- Axes to be excluded during center calculation. If None, all
1020
- axes are considered.
1135
+ - ``'mean'``: Arithmetic mean.
1136
+ - ``'median'``: Spatial median.
1137
+
1138
+ nan_safe : bool, optional
1139
+ Ensure `method` is applied to same elements of reference arrays.
1140
+ By default, distribute NaNs among reference arrays before applying
1141
+ `method`.
1142
+ reverse : bool, optional
1143
+ Reverse calibration.
1021
1144
 
1022
1145
  Returns
1023
1146
  -------
@@ -1031,6 +1154,7 @@ def phasor_calibrate(
1031
1154
  ValueError
1032
1155
  The array shapes of `real` and `imag`, or `reference_real` and
1033
1156
  `reference_imag` do not match.
1157
+ Number of harmonics does not match the first axis of `real` and `imag`.
1034
1158
 
1035
1159
  See Also
1036
1160
  --------
@@ -1050,11 +1174,13 @@ def phasor_calibrate(
1050
1174
  imag,
1051
1175
  *polar_from_reference_phasor(
1052
1176
  *phasor_center(
1177
+ reference_mean,
1053
1178
  reference_real,
1054
1179
  reference_imag,
1055
1180
  skip_axis,
1056
1181
  method,
1057
- ),
1182
+ nan_safe,
1183
+ )[1:],
1058
1184
  *phasor_from_lifetime(
1059
1185
  frequency,
1060
1186
  lifetime,
@@ -1081,6 +1207,7 @@ def phasor_calibrate(
1081
1207
  >>> phasor_calibrate(
1082
1208
  ... [0.1, 0.2, 0.3],
1083
1209
  ... [0.4, 0.5, 0.6],
1210
+ ... [1.0, 1.0, 1.0],
1084
1211
  ... [0.2, 0.3, 0.4],
1085
1212
  ... [0.5, 0.6, 0.7],
1086
1213
  ... frequency=80,
@@ -1093,6 +1220,7 @@ def phasor_calibrate(
1093
1220
  >>> phasor_calibrate(
1094
1221
  ... [0.0658, 0.132, 0.198],
1095
1222
  ... [0.2657, 0.332, 0.399],
1223
+ ... [1.0, 1.0, 1.0],
1096
1224
  ... [0.2, 0.3, 0.4],
1097
1225
  ... [0.5, 0.6, 0.7],
1098
1226
  ... frequency=80,
@@ -1102,20 +1230,52 @@ def phasor_calibrate(
1102
1230
  (array([0.1, 0.2, 0.3]), array([0.4, 0.5, 0.6]))
1103
1231
 
1104
1232
  """
1105
- re = numpy.asarray(real)
1106
- im = numpy.asarray(imag)
1107
- if re.shape != im.shape:
1108
- raise ValueError(f'real.shape={re.shape} != imag.shape={im.shape}')
1109
- ref_re = numpy.asarray(reference_real)
1110
- ref_im = numpy.asarray(reference_imag)
1111
- if ref_re.shape != ref_im.shape:
1233
+ real = numpy.asarray(real)
1234
+ imag = numpy.asarray(imag)
1235
+ reference_mean = numpy.asarray(reference_mean)
1236
+ reference_real = numpy.asarray(reference_real)
1237
+ reference_imag = numpy.asarray(reference_imag)
1238
+
1239
+ if real.shape != imag.shape:
1240
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
1241
+ if reference_real.shape != reference_imag.shape:
1242
+ raise ValueError(f'{reference_real.shape=} != {reference_imag.shape=}')
1243
+
1244
+ has_harmonic_axis = reference_mean.ndim + 1 == reference_real.ndim
1245
+ harmonic, _ = parse_harmonic(
1246
+ harmonic, reference_real.shape[0] if has_harmonic_axis else None
1247
+ )
1248
+
1249
+ if has_harmonic_axis:
1250
+ if real.ndim == 0:
1251
+ raise ValueError(f'{real.shape=} != {len(harmonic)=}')
1252
+ if real.shape[0] != len(harmonic):
1253
+ raise ValueError(f'{real.shape[0]=} != {len(harmonic)=}')
1254
+ if reference_real.shape[0] != len(harmonic):
1255
+ raise ValueError(f'{reference_real.shape[0]=} != {len(harmonic)=}')
1256
+ if reference_mean.shape != reference_real.shape[1:]:
1257
+ raise ValueError(
1258
+ f'{reference_mean.shape=} != {reference_real.shape[1:]=}'
1259
+ )
1260
+ elif reference_mean.shape != reference_real.shape:
1261
+ raise ValueError(f'{reference_mean.shape=} != {reference_real.shape=}')
1262
+ elif len(harmonic) > 1:
1112
1263
  raise ValueError(
1113
- f'reference_real.shape={ref_re.shape} '
1114
- f'!= reference_imag.shape{ref_im.shape}'
1264
+ f'{reference_mean.shape=} does not have harmonic axis'
1115
1265
  )
1116
- measured_re, measured_im = phasor_center(
1117
- reference_real, reference_imag, skip_axis=skip_axis, method=method
1266
+
1267
+ frequency = numpy.asarray(frequency)
1268
+ frequency = frequency * harmonic
1269
+
1270
+ _, measured_re, measured_im = phasor_center(
1271
+ reference_mean,
1272
+ reference_real,
1273
+ reference_imag,
1274
+ skip_axis=skip_axis,
1275
+ method=method,
1276
+ nan_safe=nan_safe,
1118
1277
  )
1278
+
1119
1279
  known_re, known_im = phasor_from_lifetime(
1120
1280
  frequency,
1121
1281
  lifetime,
@@ -1123,27 +1283,26 @@ def phasor_calibrate(
1123
1283
  preexponential=preexponential,
1124
1284
  unit_conversion=unit_conversion,
1125
1285
  )
1286
+
1126
1287
  phi_zero, mod_zero = polar_from_reference_phasor(
1127
1288
  measured_re, measured_im, known_re, known_im
1128
1289
  )
1290
+
1129
1291
  if numpy.ndim(phi_zero) > 0:
1130
1292
  if reverse:
1131
1293
  numpy.negative(phi_zero, out=phi_zero)
1132
1294
  numpy.reciprocal(mod_zero, out=mod_zero)
1133
- _, axis = _parse_skip_axis(skip_axis, re.ndim)
1295
+ _, axis = _parse_skip_axis(
1296
+ skip_axis, real.ndim - int(has_harmonic_axis), has_harmonic_axis
1297
+ )
1134
1298
  if axis is not None:
1135
- phi_zero = numpy.expand_dims(
1136
- phi_zero,
1137
- axis=axis,
1138
- )
1139
- mod_zero = numpy.expand_dims(
1140
- mod_zero,
1141
- axis=axis,
1142
- )
1299
+ phi_zero = numpy.expand_dims(phi_zero, axis=axis)
1300
+ mod_zero = numpy.expand_dims(mod_zero, axis=axis)
1143
1301
  elif reverse:
1144
1302
  phi_zero = -phi_zero
1145
1303
  mod_zero = 1.0 / mod_zero
1146
- return phasor_transform(re, im, phi_zero, mod_zero)
1304
+
1305
+ return phasor_transform(real, imag, phi_zero, mod_zero)
1147
1306
 
1148
1307
 
1149
1308
  def phasor_transform(
@@ -2643,39 +2802,59 @@ def phasor_to_principal_plane(
2643
2802
  )
2644
2803
 
2645
2804
 
2646
- def phasor_filter(
2805
+ def phasor_filter_median(
2806
+ mean: ArrayLike,
2647
2807
  real: ArrayLike,
2648
2808
  imag: ArrayLike,
2649
2809
  /,
2650
2810
  *,
2651
- method: Literal['median'] = 'median',
2652
2811
  repeat: int = 1,
2812
+ size: int = 3,
2813
+ skip_axis: int | Sequence[int] | None = None,
2814
+ use_scipy: bool = False,
2815
+ num_threads: int | None = None,
2653
2816
  **kwargs: Any,
2654
- ) -> tuple[NDArray[Any], NDArray[Any]]:
2655
- """Return filtered phasor coordinates.
2817
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
2818
+ """Return median-filtered phasor coordinates.
2656
2819
 
2657
- By default, a median filter is applied to the real and imaginary
2658
- components of phasor coordinates once with a kernel size of 3
2659
- multiplied by the number of dimensions of the input arrays.
2820
+ By default, apply a NaN-aware median filter independently to the real
2821
+ and imaginary components of phasor coordinates once with a kernel size of 3
2822
+ multiplied by the number of dimensions of the input arrays. Return the
2823
+ intensity unchanged.
2660
2824
 
2661
2825
  Parameters
2662
2826
  ----------
2827
+ mean : array_like
2828
+ Intensity of phasor coordinates.
2663
2829
  real : array_like
2664
2830
  Real component of phasor coordinates to be filtered.
2665
2831
  imag : array_like
2666
2832
  Imaginary component of phasor coordinates to be filtered.
2667
- method : str, optional
2668
- Method used for filtering:
2669
-
2670
- - ``'median'``: Spatial median of phasor coordinates.
2671
-
2672
2833
  repeat : int, optional
2673
- Number of times to apply filter. The default is 1.
2834
+ Number of times to apply median filter. The default is 1.
2835
+ size : int, optional
2836
+ Size of median filter kernel. The default is 3.
2837
+ skip_axis : int or sequence of int, optional
2838
+ Axes in `mean` to exclude from filter.
2839
+ By default, all axes except harmonics are included.
2840
+ use_scipy : bool, optional
2841
+ Use :py:func:`scipy.ndimage.median_filter`.
2842
+ This function has undefined behavior if the input arrays contain
2843
+ `NaN` values but is faster when filtering more than 2 dimensions.
2844
+ See `issue #87 <https://github.com/phasorpy/phasorpy/issues/87>`_.
2845
+ num_threads : int, optional
2846
+ Number of OpenMP threads to use for parallelization.
2847
+ Applies to filtering in two dimensions when not using scipy.
2848
+ By default, multi-threading is disabled.
2849
+ If zero, up to half of logical CPUs are used.
2850
+ OpenMP may not be available on all platforms.
2674
2851
  **kwargs
2675
2852
  Optional arguments passed to :py:func:`scipy.ndimage.median_filter`.
2676
2853
 
2677
2854
  Returns
2678
2855
  -------
2856
+ mean : ndarray
2857
+ Unchanged intensity of phasor coordinates.
2679
2858
  real : ndarray
2680
2859
  Filtered real component of phasor coordinates.
2681
2860
  imag : ndarray
@@ -2684,53 +2863,135 @@ def phasor_filter(
2684
2863
  Raises
2685
2864
  ------
2686
2865
  ValueError
2687
- If the specified method is not supported.
2688
- The array shapes of `real` and `imag` do not match.
2689
- If `repeat` is less than 1.
2690
-
2691
- Notes
2692
- -----
2693
- For now, only the median filter method is implemented.
2694
- Additional filtering methods may be added in the future.
2695
-
2696
- The implementation of the median filter method is based on
2697
- :py:func:`scipy.ndimage.median_filter`,
2698
- which has undefined behavior if the input arrays contain `NaN` values.
2699
- See `issue #87 <https://github.com/phasorpy/phasorpy/issues/87>`_.
2866
+ If `repeat` is less than 0.
2867
+ If `size` is less than 1.
2868
+ The array shapes of `mean`, `real`, and `imag` do not match.
2700
2869
 
2701
2870
  Examples
2702
2871
  --------
2703
2872
  Apply three times a median filter with a kernel size of three:
2704
2873
 
2705
- >>> phasor_filter(
2706
- ... [[0, 0, 0], [5, 5, 5], [2, 2, 2]],
2707
- ... [[3, 3, 3], [6, 6, 6], [4, 4, 4]],
2874
+ >>> mean, real, imag = phasor_filter_median(
2875
+ ... [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
2876
+ ... [[0.0, 0.0, 0.0], [0.5, 0.5, 0.5], [0.2, 0.2, 0.2]],
2877
+ ... [[0.3, 0.3, 0.3], [0.6, math.nan, 0.6], [0.4, 0.4, 0.4]],
2708
2878
  ... size=3,
2709
2879
  ... repeat=3,
2710
2880
  ... )
2711
- (array([[0, 0, 0],
2712
- [2, 2, 2],
2713
- [2, 2, 2]]),
2714
- array([[3, 3, 3],
2715
- [4, 4, 4],
2716
- [4, 4, 4]]))
2881
+ >>> mean
2882
+ array([[1, 2, 3],
2883
+ [4, 5, 6],
2884
+ [7, 8, 9]])
2885
+ >>> real
2886
+ array([[0, 0, 0],
2887
+ [0.2, 0.2, 0.2],
2888
+ [0.2, 0.2, 0.2]])
2889
+ >>> imag
2890
+ array([[0.3, 0.3, 0.3],
2891
+ [0.4, nan, 0.4],
2892
+ [0.4, 0.4, 0.4]])
2717
2893
 
2718
2894
  """
2719
- methods = {'median': _median_filter}
2720
- if method not in methods:
2721
- raise ValueError(
2722
- f"Method not supported, supported methods are: "
2723
- f"{', '.join(methods)}"
2724
- )
2725
- real = numpy.asarray(real)
2726
- imag = numpy.asarray(imag)
2895
+ if repeat < 0:
2896
+ raise ValueError(f'{repeat=} < 0')
2897
+ if size < 1:
2898
+ raise ValueError(f'{size=} < 1')
2899
+ if size == 1:
2900
+ # no need to filter
2901
+ repeat = 0
2727
2902
 
2903
+ mean = numpy.asarray(mean)
2904
+ if use_scipy or repeat == 0: # or using nD numpy filter
2905
+ real = numpy.asarray(real)
2906
+ elif isinstance(real, numpy.ndarray) and real.dtype == numpy.float32:
2907
+ real = real.copy()
2908
+ else:
2909
+ real = numpy.array(real, numpy.float64, copy=True)
2910
+ if use_scipy or repeat == 0: # or using nD numpy filter
2911
+ imag = numpy.asarray(imag)
2912
+ elif isinstance(imag, numpy.ndarray) and imag.dtype == numpy.float32:
2913
+ imag = imag.copy()
2914
+ else:
2915
+ imag = numpy.array(imag, numpy.float64, copy=True)
2916
+
2917
+ if mean.shape != real.shape[-mean.ndim if mean.ndim else 1 :]:
2918
+ raise ValueError(f'{mean.shape=} != {real.shape=}')
2728
2919
  if real.shape != imag.shape:
2729
2920
  raise ValueError(f'{real.shape=} != {imag.shape=}')
2730
- if repeat < 1:
2731
- raise ValueError(f'{repeat=} < 1')
2732
2921
 
2733
- return methods[method](real, imag, repeat, **kwargs)
2922
+ prepend_axis = mean.ndim + 1 == real.ndim
2923
+ _, axes = _parse_skip_axis(skip_axis, mean.ndim, prepend_axis)
2924
+
2925
+ # in case mean is also filtered
2926
+ # if prepend_axis:
2927
+ # mean = numpy.expand_dims(mean, axis=0)
2928
+ # ...
2929
+ # if prepend_axis:
2930
+ # mean = numpy.asarray(mean[0])
2931
+
2932
+ if repeat == 0:
2933
+ # no need to call filter
2934
+ return mean, real, imag
2935
+
2936
+ if use_scipy:
2937
+ # use scipy NaN-unaware fallback
2938
+ from scipy.ndimage import median_filter
2939
+
2940
+ kwargs.pop('axes', None)
2941
+
2942
+ for _ in range(repeat):
2943
+ real = median_filter(real, size=size, axes=axes, **kwargs)
2944
+ imag = median_filter(imag, size=size, axes=axes, **kwargs)
2945
+
2946
+ return mean, numpy.asarray(real), numpy.asarray(imag)
2947
+
2948
+ if len(axes) != 2:
2949
+ # n-dimensional median filter using numpy
2950
+ from numpy.lib.stride_tricks import sliding_window_view
2951
+
2952
+ kernel_shape = tuple(
2953
+ size if i in axes else 1 for i in range(real.ndim)
2954
+ )
2955
+ pad_width = [
2956
+ (s // 2, s // 2) if s > 1 else (0, 0) for s in kernel_shape
2957
+ ]
2958
+ axis = tuple(range(-real.ndim, 0))
2959
+
2960
+ nan_mask = numpy.isnan(real)
2961
+ for _ in range(repeat):
2962
+ real = numpy.pad(real, pad_width, mode='edge')
2963
+ real = sliding_window_view(real, kernel_shape)
2964
+ real = numpy.nanmedian(real, axis=axis)
2965
+ real = numpy.where(nan_mask, numpy.nan, real)
2966
+
2967
+ nan_mask = numpy.isnan(imag)
2968
+ for _ in range(repeat):
2969
+ imag = numpy.pad(imag, pad_width, mode='edge')
2970
+ imag = sliding_window_view(imag, kernel_shape)
2971
+ imag = numpy.nanmedian(imag, axis=axis)
2972
+ imag = numpy.where(nan_mask, numpy.nan, imag)
2973
+
2974
+ return mean, real, imag
2975
+
2976
+ # 2-dimensional median filter using optimized Cython implementation
2977
+ num_threads = number_threads(num_threads)
2978
+
2979
+ buffer = numpy.empty(
2980
+ tuple(real.shape[axis] for axis in axes), dtype=real.dtype
2981
+ )
2982
+
2983
+ for index in numpy.ndindex(
2984
+ *[real.shape[ax] for ax in range(real.ndim) if ax not in axes]
2985
+ ):
2986
+ index_list: list[int | slice] = list(index)
2987
+ for ax in axes:
2988
+ index_list = index_list[:ax] + [slice(None)] + index_list[ax:]
2989
+ full_index = tuple(index_list)
2990
+
2991
+ _median_filter_2d(real[full_index], buffer, size, repeat, num_threads)
2992
+ _median_filter_2d(imag[full_index], buffer, size, repeat, num_threads)
2993
+
2994
+ return mean, real, imag
2734
2995
 
2735
2996
 
2736
2997
  def phasor_threshold(
@@ -2750,6 +3011,7 @@ def phasor_threshold(
2750
3011
  modulation_min: ArrayLike | None = None,
2751
3012
  modulation_max: ArrayLike | None = None,
2752
3013
  open_interval: bool = False,
3014
+ detect_harmonics: bool = True,
2753
3015
  **kwargs: Any,
2754
3016
  ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
2755
3017
  """Return phasor coordinates with values out of interval replaced by NaN.
@@ -2759,11 +3021,13 @@ def phasor_threshold(
2759
3021
  Phasor coordinates smaller than minimum thresholds or larger than maximum
2760
3022
  thresholds are replaced NaN.
2761
3023
  No threshold is applied by default.
3024
+ NaNs in `mean` or any `real` and `imag` harmonic are propagated to
3025
+ `mean` and all harmonics in `real` and `imag`.
2762
3026
 
2763
3027
  Parameters
2764
3028
  ----------
2765
3029
  mean : array_like
2766
- Mean intensity of phasor coordinates.
3030
+ Intensity of phasor coordinates.
2767
3031
  real : array_like
2768
3032
  Real component of phasor coordinates.
2769
3033
  imag : array_like
@@ -2791,8 +3055,12 @@ def phasor_threshold(
2791
3055
  open_interval : bool, optional
2792
3056
  If true, the interval is open, and the threshold values are
2793
3057
  not included in the interval.
2794
- If False, the interval is closed, and the threshold values are
2795
- included in the interval. The default is False.
3058
+ If false (default), the interval is closed, and the threshold values
3059
+ are included in the interval.
3060
+ detect_harmonics : bool, optional
3061
+ By default, detect presence of multiple harmonics from array shapes.
3062
+ If false, no harmonics are assumed to be present, and the function
3063
+ behaves like a numpy universal function.
2796
3064
  **kwargs
2797
3065
  Optional `arguments passed to numpy universal functions
2798
3066
  <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
@@ -2800,7 +3068,7 @@ def phasor_threshold(
2800
3068
  Returns
2801
3069
  -------
2802
3070
  mean : ndarray
2803
- Thresholded mean intensity of phasor coordinates.
3071
+ Thresholded intensity of phasor coordinates.
2804
3072
  real : ndarray
2805
3073
  Thresholded real component of phasor coordinates.
2806
3074
  imag : ndarray
@@ -2875,96 +3143,144 @@ def phasor_threshold(
2875
3143
  else:
2876
3144
  threshold_mean_only = False
2877
3145
 
2878
- if threshold_mean_only is None:
2879
- return _phasor_threshold_nan( # type: ignore[no-any-return]
2880
- mean, real, imag, **kwargs
3146
+ if detect_harmonics:
3147
+ mean = numpy.asarray(mean)
3148
+ real = numpy.asarray(real)
3149
+ imag = numpy.asarray(imag)
3150
+
3151
+ shape = numpy.broadcast_shapes(mean.shape, real.shape, imag.shape)
3152
+ ndim = len(shape)
3153
+
3154
+ has_harmonic_axis = (
3155
+ # detect multi-harmonic in axis 0
3156
+ mean.ndim + 1 == ndim
3157
+ and real.shape == shape
3158
+ and imag.shape == shape
3159
+ and mean.shape == shape[-mean.ndim if mean.ndim else 1 :]
2881
3160
  )
3161
+ else:
3162
+ has_harmonic_axis = False
2882
3163
 
2883
- if threshold_mean_only:
3164
+ if threshold_mean_only is None:
3165
+ mean, real, imag = _phasor_threshold_nan(mean, real, imag, **kwargs)
3166
+
3167
+ elif threshold_mean_only:
2884
3168
  mean_func = (
2885
3169
  _phasor_threshold_mean_open
2886
3170
  if open_interval
2887
3171
  else _phasor_threshold_mean_closed
2888
3172
  )
2889
- return mean_func( # type: ignore[no-any-return]
3173
+ mean, real, imag = mean_func(
2890
3174
  mean, real, imag, mean_min, mean_max, **kwargs
2891
3175
  )
2892
3176
 
2893
- func = (
2894
- _phasor_threshold_open if open_interval else _phasor_threshold_closed
2895
- )
2896
- return func( # type: ignore[no-any-return]
2897
- mean,
2898
- real,
2899
- imag,
2900
- mean_min,
2901
- mean_max,
2902
- real_min,
2903
- real_max,
2904
- imag_min,
2905
- imag_max,
2906
- phase_min,
2907
- phase_max,
2908
- modulation_min,
2909
- modulation_max,
2910
- **kwargs,
2911
- )
3177
+ else:
3178
+ func = (
3179
+ _phasor_threshold_open
3180
+ if open_interval
3181
+ else _phasor_threshold_closed
3182
+ )
3183
+ mean, real, imag = func(
3184
+ mean,
3185
+ real,
3186
+ imag,
3187
+ mean_min,
3188
+ mean_max,
3189
+ real_min,
3190
+ real_max,
3191
+ imag_min,
3192
+ imag_max,
3193
+ phase_min,
3194
+ phase_max,
3195
+ modulation_min,
3196
+ modulation_max,
3197
+ **kwargs,
3198
+ )
3199
+
3200
+ mean = numpy.asarray(mean)
3201
+ real = numpy.asarray(real)
3202
+ imag = numpy.asarray(imag)
3203
+ if has_harmonic_axis and mean.ndim > 0:
3204
+ # propagate NaN to all dimensions
3205
+ mean = numpy.mean(mean, axis=0, keepdims=True)
3206
+ mask = numpy.where(numpy.isnan(mean), numpy.nan, 1.0)
3207
+ numpy.multiply(real, mask, out=real)
3208
+ numpy.multiply(imag, mask, out=imag)
3209
+ # remove harmonic dimension created by broadcasting
3210
+ mean = numpy.asarray(numpy.asarray(mean)[0])
3211
+
3212
+ return mean, real, imag
2912
3213
 
2913
3214
 
2914
3215
  def phasor_center(
3216
+ mean: ArrayLike,
2915
3217
  real: ArrayLike,
2916
3218
  imag: ArrayLike,
2917
3219
  /,
2918
3220
  *,
2919
3221
  skip_axis: int | Sequence[int] | None = None,
2920
3222
  method: Literal['mean', 'median'] = 'mean',
3223
+ nan_safe: bool = True,
2921
3224
  **kwargs: Any,
2922
- ) -> tuple[NDArray[Any], NDArray[Any]]:
3225
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
2923
3226
  """Return center of phasor coordinates.
2924
3227
 
2925
3228
  Parameters
2926
3229
  ----------
3230
+ mean : array_like
3231
+ Intensity of phasor coordinates.
2927
3232
  real : array_like
2928
3233
  Real component of phasor coordinates.
2929
3234
  imag : array_like
2930
3235
  Imaginary component of phasor coordinates.
2931
3236
  skip_axis : int or sequence of int, optional
2932
- Axes to be excluded during center calculation. If None, all
2933
- axes are considered.
3237
+ Axes in `mean` to excluded from center calculation.
3238
+ By default, all axes except harmonics are included.
2934
3239
  method : str, optional
2935
3240
  Method used for center calculation:
2936
3241
 
2937
3242
  - ``'mean'``: Arithmetic mean of phasor coordinates.
2938
3243
  - ``'median'``: Spatial median of phasor coordinates.
2939
3244
 
3245
+ nan_safe : bool, optional
3246
+ Ensure `method` is applied to same elements of input arrays.
3247
+ By default, distribute NaNs among input arrays before applying
3248
+ `method`. May be disabled if phasor coordinates were filtered by
3249
+ :py:func:`phasor_threshold`.
2940
3250
  **kwargs
2941
3251
  Optional arguments passed to :py:func:`numpy.nanmean` or
2942
3252
  :py:func:`numpy.nanmedian`.
2943
3253
 
2944
3254
  Returns
2945
3255
  -------
3256
+ mean_center : ndarray
3257
+ Intensity center coordinates.
2946
3258
  real_center : ndarray
2947
- Real center coordinates calculated based on the specified method.
3259
+ Real center coordinates.
2948
3260
  imag_center : ndarray
2949
- Imaginary center coordinates calculated based on the specified method.
3261
+ Imaginary center coordinates.
2950
3262
 
2951
3263
  Raises
2952
3264
  ------
2953
3265
  ValueError
2954
3266
  If the specified method is not supported.
2955
- If the shapes of the `real` and `imag` do not match.
3267
+ If the shapes of `mean`, `real`, and `imag` do not match.
2956
3268
 
2957
3269
  Examples
2958
3270
  --------
2959
- Compute center coordinates with the 'mean' method:
3271
+ Compute center coordinates with the default 'mean' method:
2960
3272
 
2961
- >>> phasor_center([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], method='mean')
2962
- (2.0, 5.0)
3273
+ >>> phasor_center(
3274
+ ... [2, 1, 2], [0.1, 0.2, 0.3], [0.4, 0.5, 0.6]
3275
+ ... ) # doctest: +NUMBER
3276
+ (1.67, 0.2, 0.5)
2963
3277
 
2964
3278
  Compute center coordinates with the 'median' method:
2965
3279
 
2966
- >>> phasor_center([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], method='median')
2967
- (2.0, 5.0)
3280
+ >>> phasor_center(
3281
+ ... [1, 2, 3], [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], method='median'
3282
+ ... )
3283
+ (2.0, 0.2, 0.5)
2968
3284
 
2969
3285
  """
2970
3286
  methods = {
@@ -2973,124 +3289,70 @@ def phasor_center(
2973
3289
  }
2974
3290
  if method not in methods:
2975
3291
  raise ValueError(
2976
- f"Method not supported, supported methods are: "
3292
+ f'Method not supported, supported methods are: '
2977
3293
  f"{', '.join(methods)}"
2978
3294
  )
3295
+
3296
+ mean = numpy.asarray(mean)
2979
3297
  real = numpy.asarray(real)
2980
3298
  imag = numpy.asarray(imag)
2981
3299
  if real.shape != imag.shape:
2982
3300
  raise ValueError(f'{real.shape=} != {imag.shape=}')
3301
+ if mean.shape != real.shape[-mean.ndim if mean.ndim else 1 :]:
3302
+ raise ValueError(f'{mean.shape=} != {real.shape=}')
2983
3303
 
2984
- _, axis = _parse_skip_axis(skip_axis, real.ndim)
3304
+ prepend_axis = mean.ndim + 1 == real.ndim
3305
+ _, axis = _parse_skip_axis(skip_axis, mean.ndim, prepend_axis)
3306
+ if prepend_axis:
3307
+ mean = numpy.expand_dims(mean, axis=0)
2985
3308
 
2986
- return methods[method](real, imag, axis=axis, **kwargs)
3309
+ if nan_safe:
3310
+ mean, real, imag = phasor_threshold(mean, real, imag)
2987
3311
 
3312
+ mean, real, imag = methods[method](mean, real, imag, axis=axis, **kwargs)
2988
3313
 
2989
- def _mean(
2990
- real: NDArray[Any], imag: NDArray[Any], /, **kwargs: Any
2991
- ) -> tuple[NDArray[Any], NDArray[Any]]:
2992
- """Return the mean center of phasor coordinates.
3314
+ if prepend_axis:
3315
+ mean = numpy.asarray(mean[0])
3316
+ return mean, real, imag
2993
3317
 
2994
- Parameters
2995
- ----------
2996
- real : ndarray
2997
- Real components of phasor coordinates.
2998
- imag : ndarray
2999
- Imaginary components of phasor coordinates.
3000
- **kwargs
3001
- Optional arguments passed to :py:func:`numpy.nanmean`.
3002
3318
 
3003
- Returns
3004
- -------
3005
- real_center : ndarray
3006
- Mean real center coordinates.
3007
- imag_center : ndarray
3008
- Mean imaginary center coordinates.
3009
-
3010
- Examples
3011
- --------
3012
- >>> _mean([1.0, 2.0, 3.0], [4.0, 5.0, 6.0])
3013
- (2.0, 5.0)
3014
-
3015
- """
3016
- return numpy.nanmean(real, **kwargs), numpy.nanmean(imag, **kwargs)
3319
+ def _mean(
3320
+ mean: NDArray[Any],
3321
+ real: NDArray[Any],
3322
+ imag: NDArray[Any],
3323
+ /,
3324
+ **kwargs: Any,
3325
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
3326
+ """Return mean center of phasor coordinates."""
3327
+ real = numpy.nanmean(real * mean, **kwargs)
3328
+ imag = numpy.nanmean(imag * mean, **kwargs)
3329
+ mean = numpy.nanmean(mean, **kwargs)
3330
+ with numpy.errstate(divide='ignore', invalid='ignore'):
3331
+ real /= mean
3332
+ imag /= mean
3333
+ return mean, real, imag
3017
3334
 
3018
3335
 
3019
3336
  def _median(
3020
- real: NDArray[Any], imag: NDArray[Any], /, **kwargs: Any
3021
- ) -> tuple[NDArray[Any], NDArray[Any]]:
3022
- """Return the spatial median center of phasor coordinates.
3023
-
3024
- Parameters
3025
- ----------
3026
- real : ndarray
3027
- Real components of the phasor coordinates.
3028
- imag : ndarray
3029
- Imaginary components of the phasor coordinates.
3030
- **kwargs
3031
- Optional arguments passed to :py:func:`numpy.nanmedian`.
3032
-
3033
- Returns
3034
- -------
3035
- real_center : ndarray
3036
- Spatial median center for real coordinates.
3037
- imag_center : ndarray
3038
- Spatial median center for imaginary coordinates.
3039
-
3040
- Examples
3041
- --------
3042
- >>> _median([1.0, 2.0, 3.0], [4.0, 5.0, 6.0])
3043
- (2.0, 5.0)
3044
-
3045
- """
3046
- return numpy.nanmedian(real, **kwargs), numpy.nanmedian(imag, **kwargs)
3047
-
3048
-
3049
- def _median_filter(
3050
- real: ArrayLike,
3051
- imag: ArrayLike,
3052
- repeat: int = 1,
3053
- size: int | tuple[int] | None = 3,
3337
+ mean: NDArray[Any],
3338
+ real: NDArray[Any],
3339
+ imag: NDArray[Any],
3340
+ /,
3054
3341
  **kwargs: Any,
3055
- ) -> tuple[NDArray[Any], NDArray[Any]]:
3056
- """Return the phasor coordinates after applying a median filter.
3057
-
3058
- Convenience wrapper around :py:func:`scipy.ndimage.median_filter`.
3059
-
3060
- Parameters
3061
- ----------
3062
- real : ndarray
3063
- Real components of the phasor coordinates.
3064
- imag : ndarray
3065
- Imaginary components of the phasor coordinates.
3066
- repeat : int, optional
3067
- Number of times to apply filter. The default is 1.
3068
- size : int or tuple of int, optional
3069
- The size of the median filter kernel. Default is 3.
3070
- **kwargs
3071
- Optional arguments passed to :py:func:`scipy.ndimage.median_filter`.
3072
-
3073
- Returns
3074
- -------
3075
- real : ndarray
3076
- Filtered real component of phasor coordinates.
3077
- imag : ndarray
3078
- Filtered imaginary component of phasor coordinates.
3079
-
3080
- """
3081
- from scipy.ndimage import median_filter
3082
-
3083
- for _ in range(repeat):
3084
- real = median_filter(real, size=size, **kwargs)
3085
- imag = median_filter(imag, size=size, **kwargs)
3086
-
3087
- return numpy.asarray(real), numpy.asarray(imag)
3342
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
3343
+ """Return spatial median center of phasor coordinates."""
3344
+ return (
3345
+ numpy.nanmedian(mean, **kwargs),
3346
+ numpy.nanmedian(real, **kwargs),
3347
+ numpy.nanmedian(imag, **kwargs),
3348
+ )
3088
3349
 
3089
3350
 
3090
3351
  def _parse_skip_axis(
3091
3352
  skip_axis: int | Sequence[int] | None,
3092
3353
  /,
3093
3354
  ndim: int,
3355
+ prepend_axis: bool = False,
3094
3356
  ) -> tuple[tuple[int, ...], tuple[int, ...]]:
3095
3357
  """Return axes to skip and not to skip.
3096
3358
 
@@ -3099,10 +3361,12 @@ def _parse_skip_axis(
3099
3361
 
3100
3362
  Parameters
3101
3363
  ----------
3102
- skip_axis : Sequence of int, or None
3364
+ skip_axis : int or sequence of int, optional
3103
3365
  Axes to skip. If None, no axes are skipped.
3104
3366
  ndim : int
3105
3367
  Dimensionality of array in which to skip axes.
3368
+ prepend_axis : bool, optional
3369
+ Prepend one dimension and include in `skip_axis`.
3106
3370
 
3107
3371
  Returns
3108
3372
  -------
@@ -3121,15 +3385,23 @@ def _parse_skip_axis(
3121
3385
  >>> _parse_skip_axis((1, -2), 5)
3122
3386
  ((1, 3), (0, 2, 4))
3123
3387
 
3388
+ >>> _parse_skip_axis((1, -2), 5, True)
3389
+ ((0, 2, 4), (1, 3, 5))
3390
+
3124
3391
  """
3125
3392
  if ndim < 0:
3126
3393
  raise ValueError(f'invalid {ndim=}')
3127
3394
  if skip_axis is None:
3395
+ if prepend_axis:
3396
+ return (0,), tuple(range(1, ndim + 1))
3128
3397
  return (), tuple(range(ndim))
3129
3398
  if not isinstance(skip_axis, Sequence):
3130
3399
  skip_axis = (skip_axis,)
3131
3400
  if any(i >= ndim or i < -ndim for i in skip_axis):
3132
- raise IndexError(f"skip_axis={skip_axis} out of range for {ndim=}")
3133
- skip_axis = tuple(sorted(int(i % ndim) for i in skip_axis))
3401
+ raise IndexError(f'skip_axis={skip_axis} out of range for {ndim=}')
3402
+ skip_axis = sorted(int(i % ndim) for i in skip_axis)
3403
+ if prepend_axis:
3404
+ skip_axis = [0] + [i + 1 for i in skip_axis]
3405
+ ndim += 1
3134
3406
  other_axis = tuple(i for i in range(ndim) if i not in skip_axis)
3135
- return skip_axis, other_axis
3407
+ return tuple(skip_axis), other_axis