phasorpy 0.2__cp311-cp311-win_amd64.whl → 0.4__cp311-cp311-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',
@@ -147,7 +149,7 @@ from ._phasorpy import (
147
149
  _polar_from_single_lifetime,
148
150
  _polar_to_apparent_lifetime,
149
151
  )
150
- from ._utils import parse_harmonic
152
+ from ._utils import parse_harmonic, parse_signal_axis
151
153
  from .utils import number_threads
152
154
 
153
155
 
@@ -155,12 +157,13 @@ def phasor_from_signal(
155
157
  signal: ArrayLike,
156
158
  /,
157
159
  *,
158
- axis: int = -1,
160
+ axis: int | str | None = None,
159
161
  harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
160
162
  sample_phase: ArrayLike | None = None,
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.
@@ -171,9 +174,10 @@ def phasor_from_signal(
171
174
  Frequency-domain, time-domain, or hyperspectral data.
172
175
  A minimum of three samples are required along `axis`.
173
176
  The samples must be uniformly spaced.
174
- axis : int, optional
177
+ axis : int or str, optional
175
178
  Axis over which to compute phasor coordinates.
176
- The default is the last axis (-1).
179
+ By default, the 'H' or 'C' axes if signal contains such dimension
180
+ names, else the last axis (-1).
177
181
  harmonic : int, sequence of int, or 'all', optional
178
182
  Harmonics to return.
179
183
  If `'all'`, return all harmonics for `signal` samples along `axis`.
@@ -201,6 +205,14 @@ def phasor_from_signal(
201
205
  dtype : dtype_like, optional
202
206
  Data type of output arrays. Either float32 or float64.
203
207
  The default is float64 unless the `signal` is float32.
208
+ normalize : bool, optional
209
+ Return normalized phasor coordinates.
210
+ If true (default), return average of `signal` along `axis` and
211
+ Fourier coefficients divided by sum of `signal` along `axis`.
212
+ Else, return sum of `signal` along `axis` and unscaled Fourier
213
+ coefficients.
214
+ Un-normalized phasor coordinates cannot be used with most of PhasorPy's
215
+ functions but may be required for intermediate processing.
204
216
  num_threads : int, optional
205
217
  Number of OpenMP threads to use for parallelization when not using FFT.
206
218
  By default, multi-threading is disabled.
@@ -231,13 +243,15 @@ def phasor_from_signal(
231
243
  See Also
232
244
  --------
233
245
  phasorpy.phasor.phasor_to_signal
246
+ phasorpy.phasor.phasor_normalize
234
247
  :ref:`sphx_glr_tutorials_benchmarks_phasorpy_phasor_from_signal.py`
235
248
 
236
249
  Notes
237
250
  -----
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:
251
+ The normalized phasor coordinates `real` (:math:`G`), `imag` (:math:`S`),
252
+ and average intensity `mean` (:math:`F_{DC}`) are calculated from
253
+ :math:`K\ge3` samples of the signal :math:`F` af `harmonic` :math:`h`
254
+ according to:
241
255
 
242
256
  .. math::
243
257
 
@@ -274,6 +288,9 @@ def phasor_from_signal(
274
288
  """
275
289
  # TODO: C-order not required by rfft?
276
290
  # TODO: preserve array subtypes?
291
+
292
+ axis, _ = parse_signal_axis(signal, axis)
293
+
277
294
  signal = numpy.asarray(signal, order='C')
278
295
  if signal.dtype.kind not in 'uif':
279
296
  raise TypeError(f'signal must be real valued, not {signal.dtype=}')
@@ -312,7 +329,9 @@ def phasor_from_signal(
312
329
  if rfft is None:
313
330
  rfft = numpy.fft.rfft
314
331
 
315
- fft: NDArray[Any] = rfft(signal, axis=axis, norm='forward')
332
+ fft: NDArray[Any] = rfft(
333
+ signal, axis=axis, norm='forward' if normalize else 'backward'
334
+ )
316
335
 
317
336
  mean = fft.take(0, axis=axis).real
318
337
  if not mean.ndim == 0:
@@ -332,10 +351,10 @@ def phasor_from_signal(
332
351
  real = numpy.moveaxis(real, axis, 0)
333
352
  imag = numpy.moveaxis(imag, axis, 0)
334
353
 
335
- # complex division by mean signal
336
- with numpy.errstate(divide='ignore', invalid='ignore'):
337
- real /= dc
338
- imag /= dc
354
+ if normalize:
355
+ with numpy.errstate(divide='ignore', invalid='ignore'):
356
+ real /= dc
357
+ imag /= dc
339
358
  numpy.negative(imag, out=imag)
340
359
 
341
360
  if not keepdims and real.ndim == 0:
@@ -369,7 +388,7 @@ def phasor_from_signal(
369
388
  phasor = numpy.empty((num_harmonics * 2 + 1, size0, size1), dtype)
370
389
  signal = signal.reshape((size0, samples, size1))
371
390
 
372
- _phasor_from_signal(phasor, signal, sincos, num_threads)
391
+ _phasor_from_signal(phasor, signal, sincos, normalize, num_threads)
373
392
 
374
393
  # restore original shape
375
394
  shape = shape0 + shape1
@@ -400,7 +419,7 @@ def phasor_to_signal(
400
419
  ----------
401
420
  mean : array_like
402
421
  Average signal intensity (DC).
403
- If not scalar, shape must match the last two dimensions of `real`.
422
+ If not scalar, shape must match the last dimensions of `real`.
404
423
  real : array_like
405
424
  Real component of phasor coordinates.
406
425
  Multiple harmonics, if any, must be in the first axis.
@@ -507,7 +526,6 @@ def phasor_to_signal(
507
526
  if len(harmonic) != real.shape[0]:
508
527
  raise ValueError(f'{len(harmonic)=} != {real.shape[0]=}')
509
528
 
510
- # complex multiplication by mean signal
511
529
  real *= mean
512
530
  imag *= mean
513
531
  numpy.negative(imag, out=imag)
@@ -952,9 +970,103 @@ def phasor_divide(
952
970
  )
953
971
 
954
972
 
973
+ def phasor_normalize(
974
+ mean_unnormalized: ArrayLike,
975
+ real_unnormalized: ArrayLike,
976
+ imag_unnormalized: ArrayLike,
977
+ /,
978
+ samples: int = 1,
979
+ dtype: DTypeLike = None,
980
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
981
+ r"""Return normalized phasor coordinates.
982
+
983
+ Use to normalize the phasor coordinates returned by
984
+ ``phasor_from_signal(..., normalize=False)``.
985
+
986
+ Parameters
987
+ ----------
988
+ mean_unnormalized : array_like
989
+ Unnormalized intensity of phasor coordinates.
990
+ real_unnormalized : array_like
991
+ Unnormalized real component of phasor coordinates.
992
+ imag_unnormalized : array_like
993
+ Unnormalized imaginary component of phasor coordinates.
994
+ samples : int, default: 1
995
+ Number of signal samples over which `mean` was integrated.
996
+ dtype : dtype_like, optional
997
+ Data type of output arrays. Either float32 or float64.
998
+ The default is float64 unless the `real` is float32.
999
+
1000
+ Returns
1001
+ -------
1002
+ mean : ndarray
1003
+ Normalized intensity.
1004
+ real : ndarray
1005
+ Normalized real component.
1006
+ imag : ndarray
1007
+ Normalized imaginary component.
1008
+
1009
+ Notes
1010
+ -----
1011
+ The average intensity `mean` (:math:`F_{DC}`) and normalized phasor
1012
+ coordinates `real` (:math:`G`) and `imag` (:math:`S`) are calculated from
1013
+ the signal `intensity` (:math:`F`), the number of `samples` (:math:`K`),
1014
+ `real_unnormalized` (:math:`G'`), and `imag_unnormalized` (:math:`S'`)
1015
+ according to:
1016
+
1017
+ .. math::
1018
+
1019
+ F_{DC} &= F / K
1020
+
1021
+ G &= G' / F
1022
+
1023
+ S &= S' / F
1024
+
1025
+ If :math:`F = 0`, the normalized phasor coordinates (:math:`G`)
1026
+ and (:math:`S`) are undefined (:math:`NaN` or :math:`\infty`).
1027
+
1028
+ Examples
1029
+ --------
1030
+ Normalize phasor coordinates with intensity integrated over 10 samples:
1031
+
1032
+ >>> phasor_normalize([0.0, 0.1], [0.0, 0.3], [0.4, 0.5], samples=10)
1033
+ (array([0, 0.01]), array([nan, 3]), array([inf, 5]))
1034
+
1035
+ Normalize multi-harmonic phasor coordinates:
1036
+
1037
+ >>> phasor_normalize(0.1, [0.0, 0.3], [0.4, 0.5], samples=10)
1038
+ (array(0.01), array([0, 3]), array([4, 5]))
1039
+
1040
+ """
1041
+ if samples < 1:
1042
+ raise ValueError(f'{samples=} < 1')
1043
+
1044
+ if (
1045
+ dtype is None
1046
+ and isinstance(real_unnormalized, numpy.ndarray)
1047
+ and real_unnormalized.dtype == numpy.float32
1048
+ ):
1049
+ real = real_unnormalized.copy()
1050
+ else:
1051
+ real = numpy.array(real_unnormalized, dtype, copy=True)
1052
+ imag = numpy.array(imag_unnormalized, real.dtype, copy=True)
1053
+ mean = numpy.array(
1054
+ mean_unnormalized, real.dtype, copy=None if samples == 1 else True
1055
+ )
1056
+
1057
+ with numpy.errstate(divide='ignore', invalid='ignore'):
1058
+ numpy.divide(real, mean, out=real)
1059
+ numpy.divide(imag, mean, out=imag)
1060
+ if samples > 1:
1061
+ numpy.divide(mean, samples, out=mean)
1062
+
1063
+ return mean, real, imag
1064
+
1065
+
955
1066
  def phasor_calibrate(
956
1067
  real: ArrayLike,
957
1068
  imag: ArrayLike,
1069
+ reference_mean: ArrayLike,
958
1070
  reference_real: ArrayLike,
959
1071
  reference_imag: ArrayLike,
960
1072
  /,
@@ -966,11 +1078,11 @@ def phasor_calibrate(
966
1078
  fraction: ArrayLike | None = None,
967
1079
  preexponential: bool = False,
968
1080
  unit_conversion: float = 1e-3,
969
- reverse: bool = False,
970
1081
  method: Literal['mean', 'median'] = 'mean',
1082
+ nan_safe: bool = True,
1083
+ reverse: bool = False,
971
1084
  ) -> tuple[NDArray[Any], NDArray[Any]]:
972
- """
973
- Return calibrated/referenced phasor coordinates.
1085
+ """Return calibrated/referenced phasor coordinates.
974
1086
 
975
1087
  Calibration of phasor coordinates from time-resolved measurements is
976
1088
  necessary to account for the instrument response function (IRF) and delays
@@ -982,10 +1094,13 @@ def phasor_calibrate(
982
1094
  Real component of phasor coordinates to be calibrated.
983
1095
  imag : array_like
984
1096
  Imaginary component of phasor coordinates to be calibrated.
1097
+ reference_mean : array_like or None
1098
+ Intensity of phasor coordinates from reference of known lifetime.
1099
+ Used to re-normalize averaged phasor coordinates.
985
1100
  reference_real : array_like
986
1101
  Real component of phasor coordinates from reference of known lifetime.
987
1102
  Must be measured with the same instrument setting as the phasor
988
- coordinates to be calibrated.
1103
+ coordinates to be calibrated. Dimensions must be the same as `real`.
989
1104
  reference_imag : array_like
990
1105
  Imaginary component of phasor coordinates from reference of known
991
1106
  lifetime.
@@ -1005,9 +1120,8 @@ def phasor_calibrate(
1005
1120
  harmonics.
1006
1121
  The default is the first harmonic (fundamental frequency).
1007
1122
  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.
1123
+ Axes in `reference_mean` to exclude from reference center calculation.
1124
+ By default, all axes except harmonics are included.
1011
1125
  fraction : array_like, optional
1012
1126
  Fractional intensities or pre-exponential amplitudes of the lifetime
1013
1127
  components. Fractions are normalized to sum to 1.
@@ -1019,14 +1133,18 @@ def phasor_calibrate(
1019
1133
  Product of `frequency` and `lifetime` units' prefix factors.
1020
1134
  The default is 1e-3 for MHz and ns, or Hz and ms.
1021
1135
  Use 1.0 for Hz and s.
1022
- reverse : bool, optional
1023
- Reverse calibration.
1024
1136
  method : str, optional
1025
- Method used for calculating center of `reference_real` and
1026
- `reference_imag`:
1137
+ Method used for calculating center of reference phasor coordinates:
1027
1138
 
1028
- - ``'mean'``: Arithmetic mean of phasor coordinates.
1029
- - ``'median'``: Spatial median of phasor coordinates.
1139
+ - ``'mean'``: Arithmetic mean.
1140
+ - ``'median'``: Spatial median.
1141
+
1142
+ nan_safe : bool, optional
1143
+ Ensure `method` is applied to same elements of reference arrays.
1144
+ By default, distribute NaNs among reference arrays before applying
1145
+ `method`.
1146
+ reverse : bool, optional
1147
+ Reverse calibration.
1030
1148
 
1031
1149
  Returns
1032
1150
  -------
@@ -1060,11 +1178,13 @@ def phasor_calibrate(
1060
1178
  imag,
1061
1179
  *polar_from_reference_phasor(
1062
1180
  *phasor_center(
1181
+ reference_mean,
1063
1182
  reference_real,
1064
1183
  reference_imag,
1065
1184
  skip_axis,
1066
1185
  method,
1067
- ),
1186
+ nan_safe,
1187
+ )[1:],
1068
1188
  *phasor_from_lifetime(
1069
1189
  frequency,
1070
1190
  lifetime,
@@ -1091,6 +1211,7 @@ def phasor_calibrate(
1091
1211
  >>> phasor_calibrate(
1092
1212
  ... [0.1, 0.2, 0.3],
1093
1213
  ... [0.4, 0.5, 0.6],
1214
+ ... [1.0, 1.0, 1.0],
1094
1215
  ... [0.2, 0.3, 0.4],
1095
1216
  ... [0.5, 0.6, 0.7],
1096
1217
  ... frequency=80,
@@ -1103,6 +1224,7 @@ def phasor_calibrate(
1103
1224
  >>> phasor_calibrate(
1104
1225
  ... [0.0658, 0.132, 0.198],
1105
1226
  ... [0.2657, 0.332, 0.399],
1227
+ ... [1.0, 1.0, 1.0],
1106
1228
  ... [0.2, 0.3, 0.4],
1107
1229
  ... [0.5, 0.6, 0.7],
1108
1230
  ... frequency=80,
@@ -1112,36 +1234,59 @@ def phasor_calibrate(
1112
1234
  (array([0.1, 0.2, 0.3]), array([0.4, 0.5, 0.6]))
1113
1235
 
1114
1236
  """
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:
1237
+ real = numpy.asarray(real)
1238
+ imag = numpy.asarray(imag)
1239
+ reference_mean = numpy.asarray(reference_mean)
1240
+ reference_real = numpy.asarray(reference_real)
1241
+ reference_imag = numpy.asarray(reference_imag)
1242
+
1243
+ if real.shape != imag.shape:
1244
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
1245
+ if reference_real.shape != reference_imag.shape:
1246
+ raise ValueError(f'{reference_real.shape=} != {reference_imag.shape=}')
1247
+
1248
+ has_harmonic_axis = reference_mean.ndim + 1 == reference_real.ndim
1249
+ harmonic, _ = parse_harmonic(
1250
+ harmonic,
1251
+ (
1252
+ reference_real.shape[0]
1253
+ if has_harmonic_axis
1254
+ and isinstance(harmonic, str)
1255
+ and harmonic == 'all'
1256
+ else None
1257
+ ),
1258
+ )
1259
+
1260
+ if has_harmonic_axis:
1261
+ if real.ndim == 0:
1262
+ raise ValueError(f'{real.shape=} != {len(harmonic)=}')
1263
+ if real.shape[0] != len(harmonic):
1264
+ raise ValueError(f'{real.shape[0]=} != {len(harmonic)=}')
1265
+ if reference_real.shape[0] != len(harmonic):
1266
+ raise ValueError(f'{reference_real.shape[0]=} != {len(harmonic)=}')
1267
+ if reference_mean.shape != reference_real.shape[1:]:
1268
+ raise ValueError(
1269
+ f'{reference_mean.shape=} != {reference_real.shape[1:]=}'
1270
+ )
1271
+ elif reference_mean.shape != reference_real.shape:
1272
+ raise ValueError(f'{reference_mean.shape=} != {reference_real.shape=}')
1273
+ elif len(harmonic) > 1:
1122
1274
  raise ValueError(
1123
- f'reference_real.shape={ref_re.shape} '
1124
- f'!= reference_imag.shape{ref_im.shape}'
1275
+ f'{reference_mean.shape=} does not have harmonic axis'
1125
1276
  )
1126
1277
 
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
1278
  frequency = numpy.asarray(frequency)
1135
1279
  frequency = frequency * harmonic
1136
1280
 
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
1281
+ _, measured_re, measured_im = phasor_center(
1282
+ reference_mean,
1283
+ reference_real,
1284
+ reference_imag,
1285
+ skip_axis=skip_axis,
1286
+ method=method,
1287
+ nan_safe=nan_safe,
1144
1288
  )
1289
+
1145
1290
  known_re, known_im = phasor_from_lifetime(
1146
1291
  frequency,
1147
1292
  lifetime,
@@ -1149,26 +1294,26 @@ def phasor_calibrate(
1149
1294
  preexponential=preexponential,
1150
1295
  unit_conversion=unit_conversion,
1151
1296
  )
1297
+
1152
1298
  phi_zero, mod_zero = polar_from_reference_phasor(
1153
1299
  measured_re, measured_im, known_re, known_im
1154
1300
  )
1301
+
1155
1302
  if numpy.ndim(phi_zero) > 0:
1156
1303
  if reverse:
1157
1304
  numpy.negative(phi_zero, out=phi_zero)
1158
1305
  numpy.reciprocal(mod_zero, out=mod_zero)
1306
+ _, axis = _parse_skip_axis(
1307
+ skip_axis, real.ndim - int(has_harmonic_axis), has_harmonic_axis
1308
+ )
1159
1309
  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
- )
1310
+ phi_zero = numpy.expand_dims(phi_zero, axis=axis)
1311
+ mod_zero = numpy.expand_dims(mod_zero, axis=axis)
1168
1312
  elif reverse:
1169
1313
  phi_zero = -phi_zero
1170
1314
  mod_zero = 1.0 / mod_zero
1171
- return phasor_transform(re, im, phi_zero, mod_zero)
1315
+
1316
+ return phasor_transform(real, imag, phi_zero, mod_zero)
1172
1317
 
1173
1318
 
1174
1319
  def phasor_transform(
@@ -1832,6 +1977,7 @@ def lifetime_fraction_from_amplitude(
1832
1977
  array([0.8, 0.2])
1833
1978
 
1834
1979
  """
1980
+ t: NDArray[numpy.float64]
1835
1981
  t = numpy.multiply(amplitude, lifetime, dtype=numpy.float64)
1836
1982
  t /= numpy.sum(t, axis=axis, keepdims=True)
1837
1983
  return t
@@ -2668,46 +2814,49 @@ def phasor_to_principal_plane(
2668
2814
  )
2669
2815
 
2670
2816
 
2671
- def phasor_filter(
2817
+ def phasor_filter_median(
2818
+ mean: ArrayLike,
2672
2819
  real: ArrayLike,
2673
2820
  imag: ArrayLike,
2674
2821
  /,
2675
2822
  *,
2676
- method: Literal['median', 'median_scipy'] = 'median',
2677
2823
  repeat: int = 1,
2678
2824
  size: int = 3,
2679
2825
  skip_axis: int | Sequence[int] | None = None,
2826
+ use_scipy: bool = False,
2680
2827
  num_threads: int | None = None,
2681
2828
  **kwargs: Any,
2682
- ) -> tuple[NDArray[Any], NDArray[Any]]:
2683
- """Return filtered phasor coordinates.
2829
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
2830
+ """Return median-filtered phasor coordinates.
2684
2831
 
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.
2832
+ By default, apply a NaN-aware median filter independently to the real
2833
+ and imaginary components of phasor coordinates once with a kernel size of 3
2834
+ multiplied by the number of dimensions of the input arrays. Return the
2835
+ intensity unchanged.
2688
2836
 
2689
2837
  Parameters
2690
2838
  ----------
2839
+ mean : array_like
2840
+ Intensity of phasor coordinates.
2691
2841
  real : array_like
2692
2842
  Real component of phasor coordinates to be filtered.
2693
2843
  imag : array_like
2694
2844
  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
2845
  repeat : int, optional
2703
- Number of times to apply filter. The default is 1.
2846
+ Number of times to apply median filter. The default is 1.
2704
2847
  size : int, optional
2705
- Size of filter kernel. The default is 3.
2848
+ Size of median filter kernel. The default is 3.
2706
2849
  skip_axis : int or sequence of int, optional
2707
- Axis or axes to skip filtering. By default all axes are filtered.
2850
+ Axes in `mean` to exclude from filter.
2851
+ By default, all axes except harmonics are included.
2852
+ use_scipy : bool, optional
2853
+ Use :py:func:`scipy.ndimage.median_filter`.
2854
+ This function has undefined behavior if the input arrays contain
2855
+ `NaN` values but is faster when filtering more than 2 dimensions.
2856
+ See `issue #87 <https://github.com/phasorpy/phasorpy/issues/87>`_.
2708
2857
  num_threads : int, optional
2709
2858
  Number of OpenMP threads to use for parallelization.
2710
- Applies to filtering in two dimensions with the `median` method only.
2859
+ Applies to filtering in two dimensions when not using scipy.
2711
2860
  By default, multi-threading is disabled.
2712
2861
  If zero, up to half of logical CPUs are used.
2713
2862
  OpenMP may not be available on all platforms.
@@ -2716,6 +2865,8 @@ def phasor_filter(
2716
2865
 
2717
2866
  Returns
2718
2867
  -------
2868
+ mean : ndarray
2869
+ Unchanged intensity of phasor coordinates.
2719
2870
  real : ndarray
2720
2871
  Filtered real component of phasor coordinates.
2721
2872
  imag : ndarray
@@ -2724,78 +2875,136 @@ def phasor_filter(
2724
2875
  Raises
2725
2876
  ------
2726
2877
  ValueError
2727
- If the specified method is not supported.
2728
2878
  If `repeat` is less than 0.
2729
2879
  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.
2880
+ The array shapes of `mean`, `real`, and `imag` do not match.
2747
2881
 
2748
2882
  Examples
2749
2883
  --------
2750
2884
  Apply three times a median filter with a kernel size of three:
2751
2885
 
2752
- >>> phasor_filter(
2753
- ... [[0, 0, 0], [5, 5, 5], [2, 2, 2]],
2754
- ... [[3, 3, 3], [6, math.nan, 6], [4, 4, 4]],
2886
+ >>> mean, real, imag = phasor_filter_median(
2887
+ ... [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
2888
+ ... [[0.0, 0.0, 0.0], [0.5, 0.5, 0.5], [0.2, 0.2, 0.2]],
2889
+ ... [[0.3, 0.3, 0.3], [0.6, math.nan, 0.6], [0.4, 0.4, 0.4]],
2755
2890
  ... size=3,
2756
2891
  ... repeat=3,
2757
2892
  ... )
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]]))
2893
+ >>> mean
2894
+ array([[1, 2, 3],
2895
+ [4, 5, 6],
2896
+ [7, 8, 9]])
2897
+ >>> real
2898
+ array([[0, 0, 0],
2899
+ [0.2, 0.2, 0.2],
2900
+ [0.2, 0.2, 0.2]])
2901
+ >>> imag
2902
+ array([[0.3, 0.3, 0.3],
2903
+ [0.4, nan, 0.4],
2904
+ [0.4, 0.4, 0.4]])
2764
2905
 
2765
2906
  """
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
2907
  if repeat < 0:
2778
2908
  raise ValueError(f'{repeat=} < 0')
2779
2909
  if size < 1:
2780
2910
  raise ValueError(f'{size=} < 1')
2911
+ if size == 1:
2912
+ # no need to filter
2913
+ repeat = 0
2781
2914
 
2782
- real = numpy.asarray(real)
2783
- imag = numpy.asarray(imag)
2915
+ mean = numpy.asarray(mean)
2916
+ if use_scipy or repeat == 0: # or using nD numpy filter
2917
+ real = numpy.asarray(real)
2918
+ elif isinstance(real, numpy.ndarray) and real.dtype == numpy.float32:
2919
+ real = real.copy()
2920
+ else:
2921
+ real = numpy.array(real, numpy.float64, copy=True)
2922
+ if use_scipy or repeat == 0: # or using nD numpy filter
2923
+ imag = numpy.asarray(imag)
2924
+ elif isinstance(imag, numpy.ndarray) and imag.dtype == numpy.float32:
2925
+ imag = imag.copy()
2926
+ else:
2927
+ imag = numpy.array(imag, numpy.float64, copy=True)
2784
2928
 
2929
+ if mean.shape != real.shape[-mean.ndim if mean.ndim else 1 :]:
2930
+ raise ValueError(f'{mean.shape=} != {real.shape=}')
2785
2931
  if real.shape != imag.shape:
2786
2932
  raise ValueError(f'{real.shape=} != {imag.shape=}')
2787
2933
 
2788
- _, axes = _parse_skip_axis(skip_axis, real.ndim)
2934
+ prepend_axis = mean.ndim + 1 == real.ndim
2935
+ _, axes = _parse_skip_axis(skip_axis, mean.ndim, prepend_axis)
2936
+
2937
+ # in case mean is also filtered
2938
+ # if prepend_axis:
2939
+ # mean = numpy.expand_dims(mean, axis=0)
2940
+ # ...
2941
+ # if prepend_axis:
2942
+ # mean = numpy.asarray(mean[0])
2943
+
2944
+ if repeat == 0:
2945
+ # no need to call filter
2946
+ return mean, real, imag
2947
+
2948
+ if use_scipy:
2949
+ # use scipy NaN-unaware fallback
2950
+ from scipy.ndimage import median_filter
2789
2951
 
2790
- if 'axes' in kwargs and method == 'median_scipy':
2791
- axes = kwargs.pop('axes')
2792
- if method == 'median':
2793
- kwargs['num_threads'] = num_threads
2952
+ kwargs.pop('axes', None)
2794
2953
 
2795
- return methods[method]( # type: ignore[no-any-return]
2796
- real, imag, axes, repeat=repeat, size=size, **kwargs
2954
+ for _ in range(repeat):
2955
+ real = median_filter(real, size=size, axes=axes, **kwargs)
2956
+ imag = median_filter(imag, size=size, axes=axes, **kwargs)
2957
+
2958
+ return mean, numpy.asarray(real), numpy.asarray(imag)
2959
+
2960
+ if len(axes) != 2:
2961
+ # n-dimensional median filter using numpy
2962
+ from numpy.lib.stride_tricks import sliding_window_view
2963
+
2964
+ kernel_shape = tuple(
2965
+ size if i in axes else 1 for i in range(real.ndim)
2966
+ )
2967
+ pad_width = [
2968
+ (s // 2, s // 2) if s > 1 else (0, 0) for s in kernel_shape
2969
+ ]
2970
+ axis = tuple(range(-real.ndim, 0))
2971
+
2972
+ nan_mask = numpy.isnan(real)
2973
+ for _ in range(repeat):
2974
+ real = numpy.pad(real, pad_width, mode='edge')
2975
+ real = sliding_window_view(real, kernel_shape)
2976
+ real = numpy.nanmedian(real, axis=axis)
2977
+ real = numpy.where(nan_mask, numpy.nan, real)
2978
+
2979
+ nan_mask = numpy.isnan(imag)
2980
+ for _ in range(repeat):
2981
+ imag = numpy.pad(imag, pad_width, mode='edge')
2982
+ imag = sliding_window_view(imag, kernel_shape)
2983
+ imag = numpy.nanmedian(imag, axis=axis)
2984
+ imag = numpy.where(nan_mask, numpy.nan, imag)
2985
+
2986
+ return mean, real, imag
2987
+
2988
+ # 2-dimensional median filter using optimized Cython implementation
2989
+ num_threads = number_threads(num_threads)
2990
+
2991
+ buffer = numpy.empty(
2992
+ tuple(real.shape[axis] for axis in axes), dtype=real.dtype
2797
2993
  )
2798
2994
 
2995
+ for index in numpy.ndindex(
2996
+ *[real.shape[ax] for ax in range(real.ndim) if ax not in axes]
2997
+ ):
2998
+ index_list: list[int | slice] = list(index)
2999
+ for ax in axes:
3000
+ index_list = index_list[:ax] + [slice(None)] + index_list[ax:]
3001
+ full_index = tuple(index_list)
3002
+
3003
+ _median_filter_2d(real[full_index], buffer, size, repeat, num_threads)
3004
+ _median_filter_2d(imag[full_index], buffer, size, repeat, num_threads)
3005
+
3006
+ return mean, real, imag
3007
+
2799
3008
 
2800
3009
  def phasor_threshold(
2801
3010
  mean: ArrayLike,
@@ -2814,6 +3023,7 @@ def phasor_threshold(
2814
3023
  modulation_min: ArrayLike | None = None,
2815
3024
  modulation_max: ArrayLike | None = None,
2816
3025
  open_interval: bool = False,
3026
+ detect_harmonics: bool = True,
2817
3027
  **kwargs: Any,
2818
3028
  ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
2819
3029
  """Return phasor coordinates with values out of interval replaced by NaN.
@@ -2823,11 +3033,13 @@ def phasor_threshold(
2823
3033
  Phasor coordinates smaller than minimum thresholds or larger than maximum
2824
3034
  thresholds are replaced NaN.
2825
3035
  No threshold is applied by default.
3036
+ NaNs in `mean` or any `real` and `imag` harmonic are propagated to
3037
+ `mean` and all harmonics in `real` and `imag`.
2826
3038
 
2827
3039
  Parameters
2828
3040
  ----------
2829
3041
  mean : array_like
2830
- Mean intensity of phasor coordinates.
3042
+ Intensity of phasor coordinates.
2831
3043
  real : array_like
2832
3044
  Real component of phasor coordinates.
2833
3045
  imag : array_like
@@ -2855,8 +3067,12 @@ def phasor_threshold(
2855
3067
  open_interval : bool, optional
2856
3068
  If true, the interval is open, and the threshold values are
2857
3069
  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.
3070
+ If false (default), the interval is closed, and the threshold values
3071
+ are included in the interval.
3072
+ detect_harmonics : bool, optional
3073
+ By default, detect presence of multiple harmonics from array shapes.
3074
+ If false, no harmonics are assumed to be present, and the function
3075
+ behaves like a numpy universal function.
2860
3076
  **kwargs
2861
3077
  Optional `arguments passed to numpy universal functions
2862
3078
  <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
@@ -2864,7 +3080,7 @@ def phasor_threshold(
2864
3080
  Returns
2865
3081
  -------
2866
3082
  mean : ndarray
2867
- Thresholded mean intensity of phasor coordinates.
3083
+ Thresholded intensity of phasor coordinates.
2868
3084
  real : ndarray
2869
3085
  Thresholded real component of phasor coordinates.
2870
3086
  imag : ndarray
@@ -2939,96 +3155,144 @@ def phasor_threshold(
2939
3155
  else:
2940
3156
  threshold_mean_only = False
2941
3157
 
2942
- if threshold_mean_only is None:
2943
- return _phasor_threshold_nan( # type: ignore[no-any-return]
2944
- mean, real, imag, **kwargs
3158
+ if detect_harmonics:
3159
+ mean = numpy.asarray(mean)
3160
+ real = numpy.asarray(real)
3161
+ imag = numpy.asarray(imag)
3162
+
3163
+ shape = numpy.broadcast_shapes(mean.shape, real.shape, imag.shape)
3164
+ ndim = len(shape)
3165
+
3166
+ has_harmonic_axis = (
3167
+ # detect multi-harmonic in axis 0
3168
+ mean.ndim + 1 == ndim
3169
+ and real.shape == shape
3170
+ and imag.shape == shape
3171
+ and mean.shape == shape[-mean.ndim if mean.ndim else 1 :]
2945
3172
  )
3173
+ else:
3174
+ has_harmonic_axis = False
2946
3175
 
2947
- if threshold_mean_only:
3176
+ if threshold_mean_only is None:
3177
+ mean, real, imag = _phasor_threshold_nan(mean, real, imag, **kwargs)
3178
+
3179
+ elif threshold_mean_only:
2948
3180
  mean_func = (
2949
3181
  _phasor_threshold_mean_open
2950
3182
  if open_interval
2951
3183
  else _phasor_threshold_mean_closed
2952
3184
  )
2953
- return mean_func( # type: ignore[no-any-return]
3185
+ mean, real, imag = mean_func(
2954
3186
  mean, real, imag, mean_min, mean_max, **kwargs
2955
3187
  )
2956
3188
 
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
- )
3189
+ else:
3190
+ func = (
3191
+ _phasor_threshold_open
3192
+ if open_interval
3193
+ else _phasor_threshold_closed
3194
+ )
3195
+ mean, real, imag = func(
3196
+ mean,
3197
+ real,
3198
+ imag,
3199
+ mean_min,
3200
+ mean_max,
3201
+ real_min,
3202
+ real_max,
3203
+ imag_min,
3204
+ imag_max,
3205
+ phase_min,
3206
+ phase_max,
3207
+ modulation_min,
3208
+ modulation_max,
3209
+ **kwargs,
3210
+ )
3211
+
3212
+ mean = numpy.asarray(mean)
3213
+ real = numpy.asarray(real)
3214
+ imag = numpy.asarray(imag)
3215
+ if has_harmonic_axis and mean.ndim > 0:
3216
+ # propagate NaN to all dimensions
3217
+ mean = numpy.mean(mean, axis=0, keepdims=True)
3218
+ mask = numpy.where(numpy.isnan(mean), numpy.nan, 1.0)
3219
+ numpy.multiply(real, mask, out=real)
3220
+ numpy.multiply(imag, mask, out=imag)
3221
+ # remove harmonic dimension created by broadcasting
3222
+ mean = numpy.asarray(numpy.asarray(mean)[0])
3223
+
3224
+ return mean, real, imag
2976
3225
 
2977
3226
 
2978
3227
  def phasor_center(
3228
+ mean: ArrayLike,
2979
3229
  real: ArrayLike,
2980
3230
  imag: ArrayLike,
2981
3231
  /,
2982
3232
  *,
2983
3233
  skip_axis: int | Sequence[int] | None = None,
2984
3234
  method: Literal['mean', 'median'] = 'mean',
3235
+ nan_safe: bool = True,
2985
3236
  **kwargs: Any,
2986
- ) -> tuple[NDArray[Any], NDArray[Any]]:
3237
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
2987
3238
  """Return center of phasor coordinates.
2988
3239
 
2989
3240
  Parameters
2990
3241
  ----------
3242
+ mean : array_like
3243
+ Intensity of phasor coordinates.
2991
3244
  real : array_like
2992
3245
  Real component of phasor coordinates.
2993
3246
  imag : array_like
2994
3247
  Imaginary component of phasor coordinates.
2995
3248
  skip_axis : int or sequence of int, optional
2996
- Axes to be excluded during center calculation. If None, all
2997
- axes are considered.
3249
+ Axes in `mean` to excluded from center calculation.
3250
+ By default, all axes except harmonics are included.
2998
3251
  method : str, optional
2999
3252
  Method used for center calculation:
3000
3253
 
3001
3254
  - ``'mean'``: Arithmetic mean of phasor coordinates.
3002
3255
  - ``'median'``: Spatial median of phasor coordinates.
3003
3256
 
3257
+ nan_safe : bool, optional
3258
+ Ensure `method` is applied to same elements of input arrays.
3259
+ By default, distribute NaNs among input arrays before applying
3260
+ `method`. May be disabled if phasor coordinates were filtered by
3261
+ :py:func:`phasor_threshold`.
3004
3262
  **kwargs
3005
3263
  Optional arguments passed to :py:func:`numpy.nanmean` or
3006
3264
  :py:func:`numpy.nanmedian`.
3007
3265
 
3008
3266
  Returns
3009
3267
  -------
3268
+ mean_center : ndarray
3269
+ Intensity center coordinates.
3010
3270
  real_center : ndarray
3011
- Real center coordinates calculated based on the specified method.
3271
+ Real center coordinates.
3012
3272
  imag_center : ndarray
3013
- Imaginary center coordinates calculated based on the specified method.
3273
+ Imaginary center coordinates.
3014
3274
 
3015
3275
  Raises
3016
3276
  ------
3017
3277
  ValueError
3018
3278
  If the specified method is not supported.
3019
- If the shapes of the `real` and `imag` do not match.
3279
+ If the shapes of `mean`, `real`, and `imag` do not match.
3020
3280
 
3021
3281
  Examples
3022
3282
  --------
3023
- Compute center coordinates with the 'mean' method:
3283
+ Compute center coordinates with the default 'mean' method:
3024
3284
 
3025
- >>> phasor_center([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], method='mean')
3026
- (2.0, 5.0)
3285
+ >>> phasor_center(
3286
+ ... [2, 1, 2], [0.1, 0.2, 0.3], [0.4, 0.5, 0.6]
3287
+ ... ) # doctest: +NUMBER
3288
+ (1.67, 0.2, 0.5)
3027
3289
 
3028
3290
  Compute center coordinates with the 'median' method:
3029
3291
 
3030
- >>> phasor_center([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], method='median')
3031
- (2.0, 5.0)
3292
+ >>> phasor_center(
3293
+ ... [1, 2, 3], [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], method='median'
3294
+ ... )
3295
+ (2.0, 0.2, 0.5)
3032
3296
 
3033
3297
  """
3034
3298
  methods = {
@@ -3040,233 +3304,67 @@ def phasor_center(
3040
3304
  f'Method not supported, supported methods are: '
3041
3305
  f"{', '.join(methods)}"
3042
3306
  )
3307
+
3308
+ mean = numpy.asarray(mean)
3043
3309
  real = numpy.asarray(real)
3044
3310
  imag = numpy.asarray(imag)
3045
3311
  if real.shape != imag.shape:
3046
3312
  raise ValueError(f'{real.shape=} != {imag.shape=}')
3313
+ if mean.shape != real.shape[-mean.ndim if mean.ndim else 1 :]:
3314
+ raise ValueError(f'{mean.shape=} != {real.shape=}')
3047
3315
 
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.
3316
+ prepend_axis = mean.ndim + 1 == real.ndim
3317
+ _, axis = _parse_skip_axis(skip_axis, mean.ndim, prepend_axis)
3318
+ if prepend_axis:
3319
+ mean = numpy.expand_dims(mean, axis=0)
3073
3320
 
3074
- Examples
3075
- --------
3076
- >>> _mean([1.0, 2.0, 3.0], [4.0, 5.0, 6.0])
3077
- (2.0, 5.0)
3321
+ if nan_safe:
3322
+ mean, real, imag = phasor_threshold(mean, real, imag)
3078
3323
 
3079
- """
3080
- return numpy.nanmean(real, **kwargs), numpy.nanmean(imag, **kwargs)
3324
+ mean, real, imag = methods[method](mean, real, imag, axis=axis, **kwargs)
3081
3325
 
3326
+ if prepend_axis:
3327
+ mean = numpy.asarray(mean[0])
3328
+ return mean, real, imag
3082
3329
 
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
3330
 
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`.
3096
-
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.
3103
-
3104
- Examples
3105
- --------
3106
- >>> _median([1.0, 2.0, 3.0], [4.0, 5.0, 6.0])
3107
- (2.0, 5.0)
3108
-
3109
- """
3110
- return numpy.nanmedian(real, **kwargs), numpy.nanmedian(imag, **kwargs)
3111
-
3112
-
3113
- def _median_filter(
3331
+ def _mean(
3332
+ mean: NDArray[Any],
3114
3333
  real: NDArray[Any],
3115
3334
  imag: NDArray[Any],
3116
- axes: Sequence[int],
3117
3335
  /,
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
3336
+ **kwargs: Any,
3337
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
3338
+ """Return mean center of phasor coordinates."""
3339
+ real = numpy.nanmean(real * mean, **kwargs)
3340
+ imag = numpy.nanmean(imag * mean, **kwargs)
3341
+ mean = numpy.nanmean(mean, **kwargs)
3342
+ with numpy.errstate(divide='ignore', invalid='ignore'):
3343
+ real /= mean
3344
+ imag /= mean
3345
+ return mean, real, imag
3215
3346
 
3216
3347
 
3217
- def _median_filter_scipy(
3348
+ def _median(
3349
+ mean: NDArray[Any],
3218
3350
  real: NDArray[Any],
3219
3351
  imag: NDArray[Any],
3220
- axes: Sequence[int],
3221
3352
  /,
3222
- *,
3223
- repeat: int = 1,
3224
- size: int = 3,
3225
3353
  **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)
3354
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
3355
+ """Return spatial median center of phasor coordinates."""
3356
+ return (
3357
+ numpy.nanmedian(mean, **kwargs),
3358
+ numpy.nanmedian(real, **kwargs),
3359
+ numpy.nanmedian(imag, **kwargs),
3360
+ )
3264
3361
 
3265
3362
 
3266
3363
  def _parse_skip_axis(
3267
3364
  skip_axis: int | Sequence[int] | None,
3268
3365
  /,
3269
3366
  ndim: int,
3367
+ prepend_axis: bool = False,
3270
3368
  ) -> tuple[tuple[int, ...], tuple[int, ...]]:
3271
3369
  """Return axes to skip and not to skip.
3272
3370
 
@@ -3275,10 +3373,12 @@ def _parse_skip_axis(
3275
3373
 
3276
3374
  Parameters
3277
3375
  ----------
3278
- skip_axis : Sequence of int, or None
3376
+ skip_axis : int or sequence of int, optional
3279
3377
  Axes to skip. If None, no axes are skipped.
3280
3378
  ndim : int
3281
3379
  Dimensionality of array in which to skip axes.
3380
+ prepend_axis : bool, optional
3381
+ Prepend one dimension and include in `skip_axis`.
3282
3382
 
3283
3383
  Returns
3284
3384
  -------
@@ -3297,15 +3397,23 @@ def _parse_skip_axis(
3297
3397
  >>> _parse_skip_axis((1, -2), 5)
3298
3398
  ((1, 3), (0, 2, 4))
3299
3399
 
3400
+ >>> _parse_skip_axis((1, -2), 5, True)
3401
+ ((0, 2, 4), (1, 3, 5))
3402
+
3300
3403
  """
3301
3404
  if ndim < 0:
3302
3405
  raise ValueError(f'invalid {ndim=}')
3303
3406
  if skip_axis is None:
3407
+ if prepend_axis:
3408
+ return (0,), tuple(range(1, ndim + 1))
3304
3409
  return (), tuple(range(ndim))
3305
3410
  if not isinstance(skip_axis, Sequence):
3306
3411
  skip_axis = (skip_axis,)
3307
3412
  if any(i >= ndim or i < -ndim for i in skip_axis):
3308
3413
  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))
3414
+ skip_axis = sorted(int(i % ndim) for i in skip_axis)
3415
+ if prepend_axis:
3416
+ skip_axis = [0] + [i + 1 for i in skip_axis]
3417
+ ndim += 1
3310
3418
  other_axis = tuple(i for i in range(ndim) if i not in skip_axis)
3311
- return skip_axis, other_axis
3419
+ return tuple(skip_axis), other_axis