phasorpy 0.2__cp310-cp310-win_amd64.whl → 0.3__cp310-cp310-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',
@@ -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.
@@ -201,6 +204,14 @@ def phasor_from_signal(
201
204
  dtype : dtype_like, optional
202
205
  Data type of output arrays. Either float32 or float64.
203
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.
204
215
  num_threads : int, optional
205
216
  Number of OpenMP threads to use for parallelization when not using FFT.
206
217
  By default, multi-threading is disabled.
@@ -231,13 +242,15 @@ def phasor_from_signal(
231
242
  See Also
232
243
  --------
233
244
  phasorpy.phasor.phasor_to_signal
245
+ phasorpy.phasor.phasor_normalize
234
246
  :ref:`sphx_glr_tutorials_benchmarks_phasorpy_phasor_from_signal.py`
235
247
 
236
248
  Notes
237
249
  -----
238
- The phasor coordinates `real` (:math:`G`), `imag` (:math:`S`), and
239
- `mean` (:math:`F_{DC}`) are calculated from :math:`K\ge3` samples of the
240
- 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:
241
254
 
242
255
  .. math::
243
256
 
@@ -312,7 +325,9 @@ def phasor_from_signal(
312
325
  if rfft is None:
313
326
  rfft = numpy.fft.rfft
314
327
 
315
- 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
+ )
316
331
 
317
332
  mean = fft.take(0, axis=axis).real
318
333
  if not mean.ndim == 0:
@@ -332,10 +347,10 @@ def phasor_from_signal(
332
347
  real = numpy.moveaxis(real, axis, 0)
333
348
  imag = numpy.moveaxis(imag, axis, 0)
334
349
 
335
- # complex division by mean signal
336
- with numpy.errstate(divide='ignore', invalid='ignore'):
337
- real /= dc
338
- imag /= dc
350
+ if normalize:
351
+ with numpy.errstate(divide='ignore', invalid='ignore'):
352
+ real /= dc
353
+ imag /= dc
339
354
  numpy.negative(imag, out=imag)
340
355
 
341
356
  if not keepdims and real.ndim == 0:
@@ -369,7 +384,7 @@ def phasor_from_signal(
369
384
  phasor = numpy.empty((num_harmonics * 2 + 1, size0, size1), dtype)
370
385
  signal = signal.reshape((size0, samples, size1))
371
386
 
372
- _phasor_from_signal(phasor, signal, sincos, num_threads)
387
+ _phasor_from_signal(phasor, signal, sincos, normalize, num_threads)
373
388
 
374
389
  # restore original shape
375
390
  shape = shape0 + shape1
@@ -400,7 +415,7 @@ def phasor_to_signal(
400
415
  ----------
401
416
  mean : array_like
402
417
  Average signal intensity (DC).
403
- If not scalar, shape must match the last two dimensions of `real`.
418
+ If not scalar, shape must match the last dimensions of `real`.
404
419
  real : array_like
405
420
  Real component of phasor coordinates.
406
421
  Multiple harmonics, if any, must be in the first axis.
@@ -507,7 +522,6 @@ def phasor_to_signal(
507
522
  if len(harmonic) != real.shape[0]:
508
523
  raise ValueError(f'{len(harmonic)=} != {real.shape[0]=}')
509
524
 
510
- # complex multiplication by mean signal
511
525
  real *= mean
512
526
  imag *= mean
513
527
  numpy.negative(imag, out=imag)
@@ -952,9 +966,103 @@ def phasor_divide(
952
966
  )
953
967
 
954
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
+
955
1062
  def phasor_calibrate(
956
1063
  real: ArrayLike,
957
1064
  imag: ArrayLike,
1065
+ reference_mean: ArrayLike,
958
1066
  reference_real: ArrayLike,
959
1067
  reference_imag: ArrayLike,
960
1068
  /,
@@ -966,11 +1074,11 @@ def phasor_calibrate(
966
1074
  fraction: ArrayLike | None = None,
967
1075
  preexponential: bool = False,
968
1076
  unit_conversion: float = 1e-3,
969
- reverse: bool = False,
970
1077
  method: Literal['mean', 'median'] = 'mean',
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,10 +1090,13 @@ 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.
@@ -1005,9 +1116,8 @@ def phasor_calibrate(
1005
1116
  harmonics.
1006
1117
  The default is the first harmonic (fundamental frequency).
1007
1118
  skip_axis : int or sequence of int, optional
1008
- Axes to be excluded during center calculation. If None, all
1009
- axes are considered, except for the first axis if multiple harmonics
1010
- are specified.
1119
+ Axes in `reference_mean` to exclude from reference center calculation.
1120
+ By default, all axes except harmonics are included.
1011
1121
  fraction : array_like, optional
1012
1122
  Fractional intensities or pre-exponential amplitudes of the lifetime
1013
1123
  components. Fractions are normalized to sum to 1.
@@ -1019,14 +1129,18 @@ def phasor_calibrate(
1019
1129
  Product of `frequency` and `lifetime` units' prefix factors.
1020
1130
  The default is 1e-3 for MHz and ns, or Hz and ms.
1021
1131
  Use 1.0 for Hz and s.
1022
- reverse : bool, optional
1023
- Reverse calibration.
1024
1132
  method : str, optional
1025
- Method used for calculating center of `reference_real` and
1026
- `reference_imag`:
1133
+ Method used for calculating center of reference phasor coordinates:
1027
1134
 
1028
- - ``'mean'``: Arithmetic mean of phasor coordinates.
1029
- - ``'median'``: Spatial median of phasor coordinates.
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.
1030
1144
 
1031
1145
  Returns
1032
1146
  -------
@@ -1060,11 +1174,13 @@ def phasor_calibrate(
1060
1174
  imag,
1061
1175
  *polar_from_reference_phasor(
1062
1176
  *phasor_center(
1177
+ reference_mean,
1063
1178
  reference_real,
1064
1179
  reference_imag,
1065
1180
  skip_axis,
1066
1181
  method,
1067
- ),
1182
+ nan_safe,
1183
+ )[1:],
1068
1184
  *phasor_from_lifetime(
1069
1185
  frequency,
1070
1186
  lifetime,
@@ -1091,6 +1207,7 @@ def phasor_calibrate(
1091
1207
  >>> phasor_calibrate(
1092
1208
  ... [0.1, 0.2, 0.3],
1093
1209
  ... [0.4, 0.5, 0.6],
1210
+ ... [1.0, 1.0, 1.0],
1094
1211
  ... [0.2, 0.3, 0.4],
1095
1212
  ... [0.5, 0.6, 0.7],
1096
1213
  ... frequency=80,
@@ -1103,6 +1220,7 @@ def phasor_calibrate(
1103
1220
  >>> phasor_calibrate(
1104
1221
  ... [0.0658, 0.132, 0.198],
1105
1222
  ... [0.2657, 0.332, 0.399],
1223
+ ... [1.0, 1.0, 1.0],
1106
1224
  ... [0.2, 0.3, 0.4],
1107
1225
  ... [0.5, 0.6, 0.7],
1108
1226
  ... frequency=80,
@@ -1112,36 +1230,52 @@ def phasor_calibrate(
1112
1230
  (array([0.1, 0.2, 0.3]), array([0.4, 0.5, 0.6]))
1113
1231
 
1114
1232
  """
1115
- re = numpy.asarray(real)
1116
- im = numpy.asarray(imag)
1117
- if re.shape != im.shape:
1118
- raise ValueError(f'real.shape={re.shape} != imag.shape={im.shape}')
1119
- ref_re = numpy.asarray(reference_real)
1120
- ref_im = numpy.asarray(reference_imag)
1121
- 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:
1122
1263
  raise ValueError(
1123
- f'reference_real.shape={ref_re.shape} '
1124
- f'!= reference_imag.shape{ref_im.shape}'
1264
+ f'{reference_mean.shape=} does not have harmonic axis'
1125
1265
  )
1126
1266
 
1127
- if harmonic == 'all' and re.ndim > 0:
1128
- harmonic, has_harmonic_axis = parse_harmonic(harmonic, re.shape[0])
1129
- else:
1130
- harmonic, has_harmonic_axis = parse_harmonic(harmonic)
1131
- if has_harmonic_axis and len(harmonic) != re.shape[0]:
1132
- raise ValueError(f'{len(harmonic)=} != real.shape[0]={re.shape[0]}')
1133
-
1134
1267
  frequency = numpy.asarray(frequency)
1135
1268
  frequency = frequency * harmonic
1136
1269
 
1137
- skip_axis, axis = _parse_skip_axis(skip_axis, re.ndim)
1138
- if has_harmonic_axis:
1139
- skip_axis = (0,) + skip_axis if 0 not in skip_axis else skip_axis
1140
- skip_axis, axis = _parse_skip_axis(skip_axis, re.ndim)
1141
-
1142
- measured_re, measured_im = phasor_center(
1143
- reference_real, reference_imag, skip_axis=skip_axis, method=method
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,
1144
1277
  )
1278
+
1145
1279
  known_re, known_im = phasor_from_lifetime(
1146
1280
  frequency,
1147
1281
  lifetime,
@@ -1149,26 +1283,26 @@ def phasor_calibrate(
1149
1283
  preexponential=preexponential,
1150
1284
  unit_conversion=unit_conversion,
1151
1285
  )
1286
+
1152
1287
  phi_zero, mod_zero = polar_from_reference_phasor(
1153
1288
  measured_re, measured_im, known_re, known_im
1154
1289
  )
1290
+
1155
1291
  if numpy.ndim(phi_zero) > 0:
1156
1292
  if reverse:
1157
1293
  numpy.negative(phi_zero, out=phi_zero)
1158
1294
  numpy.reciprocal(mod_zero, out=mod_zero)
1295
+ _, axis = _parse_skip_axis(
1296
+ skip_axis, real.ndim - int(has_harmonic_axis), has_harmonic_axis
1297
+ )
1159
1298
  if axis is not None:
1160
- phi_zero = numpy.expand_dims(
1161
- phi_zero,
1162
- axis=axis,
1163
- )
1164
- mod_zero = numpy.expand_dims(
1165
- mod_zero,
1166
- axis=axis,
1167
- )
1299
+ phi_zero = numpy.expand_dims(phi_zero, axis=axis)
1300
+ mod_zero = numpy.expand_dims(mod_zero, axis=axis)
1168
1301
  elif reverse:
1169
1302
  phi_zero = -phi_zero
1170
1303
  mod_zero = 1.0 / mod_zero
1171
- return phasor_transform(re, im, phi_zero, mod_zero)
1304
+
1305
+ return phasor_transform(real, imag, phi_zero, mod_zero)
1172
1306
 
1173
1307
 
1174
1308
  def phasor_transform(
@@ -2668,46 +2802,49 @@ def phasor_to_principal_plane(
2668
2802
  )
2669
2803
 
2670
2804
 
2671
- def phasor_filter(
2805
+ def phasor_filter_median(
2806
+ mean: ArrayLike,
2672
2807
  real: ArrayLike,
2673
2808
  imag: ArrayLike,
2674
2809
  /,
2675
2810
  *,
2676
- method: Literal['median', 'median_scipy'] = 'median',
2677
2811
  repeat: int = 1,
2678
2812
  size: int = 3,
2679
2813
  skip_axis: int | Sequence[int] | None = None,
2814
+ use_scipy: bool = False,
2680
2815
  num_threads: int | None = None,
2681
2816
  **kwargs: Any,
2682
- ) -> tuple[NDArray[Any], NDArray[Any]]:
2683
- """Return filtered phasor coordinates.
2817
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
2818
+ """Return median-filtered phasor coordinates.
2684
2819
 
2685
- By default, a median filter is applied independently to the real and
2686
- imaginary components of phasor coordinates once with a kernel size of 3
2687
- 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.
2688
2824
 
2689
2825
  Parameters
2690
2826
  ----------
2827
+ mean : array_like
2828
+ Intensity of phasor coordinates.
2691
2829
  real : array_like
2692
2830
  Real component of phasor coordinates to be filtered.
2693
2831
  imag : array_like
2694
2832
  Imaginary component of phasor coordinates to be filtered.
2695
- method : str, optional
2696
- Method used for filtering:
2697
-
2698
- - ``'median'``: Spatial median of phasor coordinates.
2699
- - ``'median_scipy'``: Spatial median of phasor coordinates
2700
- based on :py:func:`scipy.ndimage.median_filter`.
2701
-
2702
2833
  repeat : int, optional
2703
- Number of times to apply filter. The default is 1.
2834
+ Number of times to apply median filter. The default is 1.
2704
2835
  size : int, optional
2705
- Size of filter kernel. The default is 3.
2836
+ Size of median filter kernel. The default is 3.
2706
2837
  skip_axis : int or sequence of int, optional
2707
- Axis or axes to skip filtering. By default all axes are filtered.
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>`_.
2708
2845
  num_threads : int, optional
2709
2846
  Number of OpenMP threads to use for parallelization.
2710
- Applies to filtering in two dimensions with the `median` method only.
2847
+ Applies to filtering in two dimensions when not using scipy.
2711
2848
  By default, multi-threading is disabled.
2712
2849
  If zero, up to half of logical CPUs are used.
2713
2850
  OpenMP may not be available on all platforms.
@@ -2716,6 +2853,8 @@ def phasor_filter(
2716
2853
 
2717
2854
  Returns
2718
2855
  -------
2856
+ mean : ndarray
2857
+ Unchanged intensity of phasor coordinates.
2719
2858
  real : ndarray
2720
2859
  Filtered real component of phasor coordinates.
2721
2860
  imag : ndarray
@@ -2724,78 +2863,136 @@ def phasor_filter(
2724
2863
  Raises
2725
2864
  ------
2726
2865
  ValueError
2727
- If the specified method is not supported.
2728
2866
  If `repeat` is less than 0.
2729
2867
  If `size` is less than 1.
2730
- The array shapes of `real` and `imag` do not match.
2731
-
2732
- Notes
2733
- -----
2734
- Additional filtering methods may be added in the future.
2735
-
2736
- The `median` method ignores `NaN` values. If the kernel contains an even
2737
- number of elements, the median is the average of the two middle elements.
2738
-
2739
- The implementation of the `median_scipy` method is based on
2740
- :py:func:`scipy.ndimage.median_filter`,
2741
- which has undefined behavior if the input arrays contain `NaN` values.
2742
- See `issue #87 <https://github.com/phasorpy/phasorpy/issues/87>`_.
2743
-
2744
- When filtering in more than two dimensions, the `median` method is
2745
- slower than the `median_scipy` method. When filtering in two
2746
- dimensions, both methods have similar performance.
2868
+ The array shapes of `mean`, `real`, and `imag` do not match.
2747
2869
 
2748
2870
  Examples
2749
2871
  --------
2750
2872
  Apply three times a median filter with a kernel size of three:
2751
2873
 
2752
- >>> phasor_filter(
2753
- ... [[0, 0, 0], [5, 5, 5], [2, 2, 2]],
2754
- ... [[3, 3, 3], [6, math.nan, 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]],
2755
2878
  ... size=3,
2756
2879
  ... repeat=3,
2757
2880
  ... )
2758
- (array([[0, 0, 0],
2759
- [2, 2, 2],
2760
- [2, 2, 2]]),
2761
- array([[3, 3, 3],
2762
- [4, nan, 4],
2763
- [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]])
2764
2893
 
2765
2894
  """
2766
- methods: dict[str, Callable[..., Any]] = {
2767
- 'median': _median_filter,
2768
- 'median_scipy': _median_filter_scipy,
2769
- }
2770
- if method not in methods:
2771
- raise ValueError(
2772
- f'Method not supported, supported methods are: '
2773
- f"{', '.join(methods)}"
2774
- )
2775
- if repeat == 0 or size == 1:
2776
- return numpy.asarray(real), numpy.asarray(imag)
2777
2895
  if repeat < 0:
2778
2896
  raise ValueError(f'{repeat=} < 0')
2779
2897
  if size < 1:
2780
2898
  raise ValueError(f'{size=} < 1')
2899
+ if size == 1:
2900
+ # no need to filter
2901
+ repeat = 0
2781
2902
 
2782
- real = numpy.asarray(real)
2783
- imag = numpy.asarray(imag)
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)
2784
2916
 
2917
+ if mean.shape != real.shape[-mean.ndim if mean.ndim else 1 :]:
2918
+ raise ValueError(f'{mean.shape=} != {real.shape=}')
2785
2919
  if real.shape != imag.shape:
2786
2920
  raise ValueError(f'{real.shape=} != {imag.shape=}')
2787
2921
 
2788
- _, axes = _parse_skip_axis(skip_axis, real.ndim)
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)
2789
2973
 
2790
- if 'axes' in kwargs and method == 'median_scipy':
2791
- axes = kwargs.pop('axes')
2792
- if method == 'median':
2793
- kwargs['num_threads'] = num_threads
2974
+ return mean, real, imag
2794
2975
 
2795
- return methods[method]( # type: ignore[no-any-return]
2796
- real, imag, axes, repeat=repeat, size=size, **kwargs
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
2797
2981
  )
2798
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
2995
+
2799
2996
 
2800
2997
  def phasor_threshold(
2801
2998
  mean: ArrayLike,
@@ -2814,6 +3011,7 @@ def phasor_threshold(
2814
3011
  modulation_min: ArrayLike | None = None,
2815
3012
  modulation_max: ArrayLike | None = None,
2816
3013
  open_interval: bool = False,
3014
+ detect_harmonics: bool = True,
2817
3015
  **kwargs: Any,
2818
3016
  ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
2819
3017
  """Return phasor coordinates with values out of interval replaced by NaN.
@@ -2823,11 +3021,13 @@ def phasor_threshold(
2823
3021
  Phasor coordinates smaller than minimum thresholds or larger than maximum
2824
3022
  thresholds are replaced NaN.
2825
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`.
2826
3026
 
2827
3027
  Parameters
2828
3028
  ----------
2829
3029
  mean : array_like
2830
- Mean intensity of phasor coordinates.
3030
+ Intensity of phasor coordinates.
2831
3031
  real : array_like
2832
3032
  Real component of phasor coordinates.
2833
3033
  imag : array_like
@@ -2855,8 +3055,12 @@ def phasor_threshold(
2855
3055
  open_interval : bool, optional
2856
3056
  If true, the interval is open, and the threshold values are
2857
3057
  not included in the interval.
2858
- If False, the interval is closed, and the threshold values are
2859
- 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.
2860
3064
  **kwargs
2861
3065
  Optional `arguments passed to numpy universal functions
2862
3066
  <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
@@ -2864,7 +3068,7 @@ def phasor_threshold(
2864
3068
  Returns
2865
3069
  -------
2866
3070
  mean : ndarray
2867
- Thresholded mean intensity of phasor coordinates.
3071
+ Thresholded intensity of phasor coordinates.
2868
3072
  real : ndarray
2869
3073
  Thresholded real component of phasor coordinates.
2870
3074
  imag : ndarray
@@ -2939,96 +3143,144 @@ def phasor_threshold(
2939
3143
  else:
2940
3144
  threshold_mean_only = False
2941
3145
 
2942
- if threshold_mean_only is None:
2943
- return _phasor_threshold_nan( # type: ignore[no-any-return]
2944
- 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 :]
2945
3160
  )
3161
+ else:
3162
+ has_harmonic_axis = False
3163
+
3164
+ if threshold_mean_only is None:
3165
+ mean, real, imag = _phasor_threshold_nan(mean, real, imag, **kwargs)
2946
3166
 
2947
- if threshold_mean_only:
3167
+ elif threshold_mean_only:
2948
3168
  mean_func = (
2949
3169
  _phasor_threshold_mean_open
2950
3170
  if open_interval
2951
3171
  else _phasor_threshold_mean_closed
2952
3172
  )
2953
- return mean_func( # type: ignore[no-any-return]
3173
+ mean, real, imag = mean_func(
2954
3174
  mean, real, imag, mean_min, mean_max, **kwargs
2955
3175
  )
2956
3176
 
2957
- func = (
2958
- _phasor_threshold_open if open_interval else _phasor_threshold_closed
2959
- )
2960
- return func( # type: ignore[no-any-return]
2961
- mean,
2962
- real,
2963
- imag,
2964
- mean_min,
2965
- mean_max,
2966
- real_min,
2967
- real_max,
2968
- imag_min,
2969
- imag_max,
2970
- phase_min,
2971
- phase_max,
2972
- modulation_min,
2973
- modulation_max,
2974
- **kwargs,
2975
- )
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
2976
3213
 
2977
3214
 
2978
3215
  def phasor_center(
3216
+ mean: ArrayLike,
2979
3217
  real: ArrayLike,
2980
3218
  imag: ArrayLike,
2981
3219
  /,
2982
3220
  *,
2983
3221
  skip_axis: int | Sequence[int] | None = None,
2984
3222
  method: Literal['mean', 'median'] = 'mean',
3223
+ nan_safe: bool = True,
2985
3224
  **kwargs: Any,
2986
- ) -> tuple[NDArray[Any], NDArray[Any]]:
3225
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
2987
3226
  """Return center of phasor coordinates.
2988
3227
 
2989
3228
  Parameters
2990
3229
  ----------
3230
+ mean : array_like
3231
+ Intensity of phasor coordinates.
2991
3232
  real : array_like
2992
3233
  Real component of phasor coordinates.
2993
3234
  imag : array_like
2994
3235
  Imaginary component of phasor coordinates.
2995
3236
  skip_axis : int or sequence of int, optional
2996
- Axes to be excluded during center calculation. If None, all
2997
- axes are considered.
3237
+ Axes in `mean` to excluded from center calculation.
3238
+ By default, all axes except harmonics are included.
2998
3239
  method : str, optional
2999
3240
  Method used for center calculation:
3000
3241
 
3001
3242
  - ``'mean'``: Arithmetic mean of phasor coordinates.
3002
3243
  - ``'median'``: Spatial median of phasor coordinates.
3003
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`.
3004
3250
  **kwargs
3005
3251
  Optional arguments passed to :py:func:`numpy.nanmean` or
3006
3252
  :py:func:`numpy.nanmedian`.
3007
3253
 
3008
3254
  Returns
3009
3255
  -------
3256
+ mean_center : ndarray
3257
+ Intensity center coordinates.
3010
3258
  real_center : ndarray
3011
- Real center coordinates calculated based on the specified method.
3259
+ Real center coordinates.
3012
3260
  imag_center : ndarray
3013
- Imaginary center coordinates calculated based on the specified method.
3261
+ Imaginary center coordinates.
3014
3262
 
3015
3263
  Raises
3016
3264
  ------
3017
3265
  ValueError
3018
3266
  If the specified method is not supported.
3019
- If the shapes of the `real` and `imag` do not match.
3267
+ If the shapes of `mean`, `real`, and `imag` do not match.
3020
3268
 
3021
3269
  Examples
3022
3270
  --------
3023
- Compute center coordinates with the 'mean' method:
3271
+ Compute center coordinates with the default 'mean' method:
3024
3272
 
3025
- >>> phasor_center([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], method='mean')
3026
- (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)
3027
3277
 
3028
3278
  Compute center coordinates with the 'median' method:
3029
3279
 
3030
- >>> phasor_center([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], method='median')
3031
- (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)
3032
3284
 
3033
3285
  """
3034
3286
  methods = {
@@ -3040,233 +3292,67 @@ def phasor_center(
3040
3292
  f'Method not supported, supported methods are: '
3041
3293
  f"{', '.join(methods)}"
3042
3294
  )
3295
+
3296
+ mean = numpy.asarray(mean)
3043
3297
  real = numpy.asarray(real)
3044
3298
  imag = numpy.asarray(imag)
3045
3299
  if real.shape != imag.shape:
3046
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=}')
3047
3303
 
3048
- _, axis = _parse_skip_axis(skip_axis, real.ndim)
3049
-
3050
- return methods[method](real, imag, axis=axis, **kwargs)
3051
-
3052
-
3053
- def _mean(
3054
- real: NDArray[Any], imag: NDArray[Any], /, **kwargs: Any
3055
- ) -> tuple[NDArray[Any], NDArray[Any]]:
3056
- """Return the mean center of phasor coordinates.
3057
-
3058
- Parameters
3059
- ----------
3060
- real : ndarray
3061
- Real components of phasor coordinates.
3062
- imag : ndarray
3063
- Imaginary components of phasor coordinates.
3064
- **kwargs
3065
- Optional arguments passed to :py:func:`numpy.nanmean`.
3066
-
3067
- Returns
3068
- -------
3069
- real_center : ndarray
3070
- Mean real center coordinates.
3071
- imag_center : ndarray
3072
- Mean imaginary center coordinates.
3073
-
3074
- Examples
3075
- --------
3076
- >>> _mean([1.0, 2.0, 3.0], [4.0, 5.0, 6.0])
3077
- (2.0, 5.0)
3078
-
3079
- """
3080
- return numpy.nanmean(real, **kwargs), numpy.nanmean(imag, **kwargs)
3081
-
3082
-
3083
- def _median(
3084
- real: NDArray[Any], imag: NDArray[Any], /, **kwargs: Any
3085
- ) -> tuple[NDArray[Any], NDArray[Any]]:
3086
- """Return the spatial median center of phasor coordinates.
3087
-
3088
- Parameters
3089
- ----------
3090
- real : ndarray
3091
- Real components of phasor coordinates.
3092
- imag : ndarray
3093
- Imaginary components of phasor coordinates.
3094
- **kwargs
3095
- Optional arguments passed to :py:func:`numpy.nanmedian`.
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)
3096
3308
 
3097
- Returns
3098
- -------
3099
- real_center : ndarray
3100
- Spatial median center of real coordinates.
3101
- imag_center : ndarray
3102
- Spatial median center of imaginary coordinates.
3309
+ if nan_safe:
3310
+ mean, real, imag = phasor_threshold(mean, real, imag)
3103
3311
 
3104
- Examples
3105
- --------
3106
- >>> _median([1.0, 2.0, 3.0], [4.0, 5.0, 6.0])
3107
- (2.0, 5.0)
3312
+ mean, real, imag = methods[method](mean, real, imag, axis=axis, **kwargs)
3108
3313
 
3109
- """
3110
- return numpy.nanmedian(real, **kwargs), numpy.nanmedian(imag, **kwargs)
3314
+ if prepend_axis:
3315
+ mean = numpy.asarray(mean[0])
3316
+ return mean, real, imag
3111
3317
 
3112
3318
 
3113
- def _median_filter(
3319
+ def _mean(
3320
+ mean: NDArray[Any],
3114
3321
  real: NDArray[Any],
3115
3322
  imag: NDArray[Any],
3116
- axes: Sequence[int],
3117
3323
  /,
3118
- *,
3119
- repeat: int = 1,
3120
- size: int = 3,
3121
- num_threads: int | None = None,
3122
- ) -> tuple[NDArray[Any], NDArray[Any]]:
3123
- """Return median-filtered phasor coordinates, ignoring NaN values.
3124
-
3125
- Parameters
3126
- ----------
3127
- real : ndarray
3128
- Real components of phasor coordinates.
3129
- imag : ndarray
3130
- Imaginary components of phasor coordinates.
3131
- axes : sequence of int
3132
- Axes along which to apply median filter.
3133
- repeat : int, optional
3134
- Number of times to apply filter. The default is 1.
3135
- size : int, optional
3136
- Size of median filter kernel. The default is 3.
3137
- num_threads : int, optional
3138
- Number of OpenMP threads to use for parallelization.
3139
- By default, multi-threading is disabled.
3140
- If zero, up to half of logical CPUs are used.
3141
- OpenMP may not be available on all platforms.
3142
-
3143
- Returns
3144
- -------
3145
- real : ndarray
3146
- Median-filtered real component of phasor coordinates.
3147
- imag : ndarray
3148
- Median-filtered imaginary component of phasor coordinates.
3149
-
3150
- """
3151
- real = numpy.asarray(real)
3152
- if real.dtype == numpy.float32:
3153
- real = real.copy()
3154
- else:
3155
- real = real.astype(float)
3156
-
3157
- imag = numpy.asarray(imag)
3158
- if imag.dtype == numpy.float32:
3159
- imag = imag.copy()
3160
- else:
3161
- imag = imag.astype(float)
3162
-
3163
- if len(axes) != 2:
3164
- # n-dimensional median filter using numpy
3165
- from numpy.lib.stride_tricks import sliding_window_view
3166
-
3167
- kernel_shape = tuple(
3168
- size if i in axes else 1 for i in range(real.ndim)
3169
- )
3170
- pad_width = [
3171
- (s // 2, s // 2) if s > 1 else (0, 0) for s in kernel_shape
3172
- ]
3173
- axis = tuple(range(-real.ndim, 0))
3174
-
3175
- nan_mask = numpy.isnan(real)
3176
- for _ in range(repeat):
3177
- real = numpy.pad(real, pad_width, mode='edge')
3178
- real = sliding_window_view(real, kernel_shape)
3179
- real = numpy.nanmedian(real, axis=axis)
3180
- real = numpy.where(nan_mask, numpy.nan, real)
3181
-
3182
- nan_mask = numpy.isnan(imag)
3183
- for _ in range(repeat):
3184
- imag = numpy.pad(imag, pad_width, mode='edge')
3185
- imag = sliding_window_view(imag, kernel_shape)
3186
- imag = numpy.nanmedian(imag, axis=axis)
3187
- imag = numpy.where(nan_mask, numpy.nan, imag)
3188
-
3189
- return real, imag
3190
-
3191
- # 2-dimensional median filter using optimized Cython implementation
3192
- num_threads = number_threads(num_threads)
3193
-
3194
- filtered_slice = numpy.empty(
3195
- tuple(real.shape[axis] for axis in axes), dtype=real.dtype
3196
- )
3197
-
3198
- for index in numpy.ndindex(
3199
- *[real.shape[ax] for ax in range(real.ndim) if ax not in axes]
3200
- ):
3201
- index_list: list[int | slice] = list(index)
3202
- for ax in axes:
3203
- index_list = index_list[:ax] + [slice(None)] + index_list[ax:]
3204
- full_index = tuple(index_list)
3205
-
3206
- _median_filter_2d(
3207
- real[full_index], filtered_slice, size, repeat, num_threads
3208
- )
3209
-
3210
- _median_filter_2d(
3211
- imag[full_index], filtered_slice, size, repeat, num_threads
3212
- )
3213
-
3214
- return real, imag
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
3215
3334
 
3216
3335
 
3217
- def _median_filter_scipy(
3336
+ def _median(
3337
+ mean: NDArray[Any],
3218
3338
  real: NDArray[Any],
3219
3339
  imag: NDArray[Any],
3220
- axes: Sequence[int],
3221
3340
  /,
3222
- *,
3223
- repeat: int = 1,
3224
- size: int = 3,
3225
3341
  **kwargs: Any,
3226
- ) -> tuple[NDArray[Any], NDArray[Any]]:
3227
- """Return median-filtered phasor coordinates.
3228
-
3229
- Convenience wrapper around :py:func:`scipy.ndimage.median_filter`.
3230
-
3231
- Parameters
3232
- ----------
3233
- real : ndarray
3234
- Real components of phasor coordinates.
3235
- imag : ndarray
3236
- Imaginary components of phasor coordinates.
3237
- axes : sequence of int
3238
- Axes along which to apply median filter.
3239
- repeat : int, optional
3240
- Number of times to apply filter. The default is 1.
3241
- size : int, optional
3242
- Size of median filter kernel. The default is 3.
3243
- **kwargs
3244
- Optional arguments passed to :py:func:`scipy.ndimage.median_filter`.
3245
-
3246
- Returns
3247
- -------
3248
- real : ndarray
3249
- Median-filtered real component of phasor coordinates.
3250
- imag : ndarray
3251
- Median-filtered imaginary component of phasor coordinates.
3252
-
3253
- """
3254
- from scipy.ndimage import median_filter
3255
-
3256
- real = numpy.asarray(real)
3257
- imag = numpy.asarray(imag)
3258
-
3259
- for _ in range(repeat):
3260
- real = median_filter(real, size=size, axes=axes, **kwargs)
3261
- imag = median_filter(imag, size=size, axes=axes, **kwargs)
3262
-
3263
- 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
+ )
3264
3349
 
3265
3350
 
3266
3351
  def _parse_skip_axis(
3267
3352
  skip_axis: int | Sequence[int] | None,
3268
3353
  /,
3269
3354
  ndim: int,
3355
+ prepend_axis: bool = False,
3270
3356
  ) -> tuple[tuple[int, ...], tuple[int, ...]]:
3271
3357
  """Return axes to skip and not to skip.
3272
3358
 
@@ -3275,10 +3361,12 @@ def _parse_skip_axis(
3275
3361
 
3276
3362
  Parameters
3277
3363
  ----------
3278
- skip_axis : Sequence of int, or None
3364
+ skip_axis : int or sequence of int, optional
3279
3365
  Axes to skip. If None, no axes are skipped.
3280
3366
  ndim : int
3281
3367
  Dimensionality of array in which to skip axes.
3368
+ prepend_axis : bool, optional
3369
+ Prepend one dimension and include in `skip_axis`.
3282
3370
 
3283
3371
  Returns
3284
3372
  -------
@@ -3297,15 +3385,23 @@ def _parse_skip_axis(
3297
3385
  >>> _parse_skip_axis((1, -2), 5)
3298
3386
  ((1, 3), (0, 2, 4))
3299
3387
 
3388
+ >>> _parse_skip_axis((1, -2), 5, True)
3389
+ ((0, 2, 4), (1, 3, 5))
3390
+
3300
3391
  """
3301
3392
  if ndim < 0:
3302
3393
  raise ValueError(f'invalid {ndim=}')
3303
3394
  if skip_axis is None:
3395
+ if prepend_axis:
3396
+ return (0,), tuple(range(1, ndim + 1))
3304
3397
  return (), tuple(range(ndim))
3305
3398
  if not isinstance(skip_axis, Sequence):
3306
3399
  skip_axis = (skip_axis,)
3307
3400
  if any(i >= ndim or i < -ndim for i in skip_axis):
3308
3401
  raise IndexError(f'skip_axis={skip_axis} out of range for {ndim=}')
3309
- skip_axis = tuple(sorted(int(i % ndim) for i in skip_axis))
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
3310
3406
  other_axis = tuple(i for i in range(ndim) if i not in skip_axis)
3311
- return skip_axis, other_axis
3407
+ return tuple(skip_axis), other_axis