phasorpy 0.1__cp312-cp312-win_amd64.whl → 0.2__cp312-cp312-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
@@ -104,7 +104,6 @@ __all__ = [
104
104
  ]
105
105
 
106
106
  import math
107
- import numbers
108
107
  from collections.abc import Sequence
109
108
  from typing import TYPE_CHECKING
110
109
 
@@ -122,6 +121,7 @@ import numpy
122
121
 
123
122
  from ._phasorpy import (
124
123
  _gaussian_signal,
124
+ _median_filter_2d,
125
125
  _phasor_at_harmonic,
126
126
  _phasor_divide,
127
127
  _phasor_from_apparent_lifetime,
@@ -180,6 +180,8 @@ def phasor_from_signal(
180
180
  Else, harmonics must be at least one and no larger than half the
181
181
  number of `signal` samples along `axis`.
182
182
  The default is the first harmonic (fundamental frequency).
183
+ A minimum of `harmonic * 2 + 1` samples are required along `axis`
184
+ to calculate correct phasor coordinates at `harmonic`.
183
185
  sample_phase : array_like, optional
184
186
  Phase values (in radians) of `signal` samples along `axis`.
185
187
  If None (default), samples are assumed to be uniformly spaced along
@@ -285,7 +287,7 @@ def phasor_from_signal(
285
287
  if dtype.kind != 'f':
286
288
  raise TypeError(f'{dtype=} not supported')
287
289
 
288
- harmonic, keepdims = parse_harmonic(harmonic, samples)
290
+ harmonic, keepdims = parse_harmonic(harmonic, samples // 2)
289
291
  num_harmonics = len(harmonic)
290
292
 
291
293
  if sample_phase is not None:
@@ -303,7 +305,7 @@ def phasor_from_signal(
303
305
  use_fft = sample_phase is None and (
304
306
  rfft is not None
305
307
  or num_harmonics > 7
306
- or num_harmonics == samples // 2
308
+ or num_harmonics >= samples // 2
307
309
  )
308
310
 
309
311
  if use_fft:
@@ -413,7 +415,7 @@ def phasor_to_signal(
413
415
  coordinates (most commonly, lower harmonics are present if the number
414
416
  of dimensions of `mean` is one less than `real`).
415
417
  If `'all'`, the harmonics in the first axis of phasor coordinates are
416
- the lower harmonics.
418
+ the lower harmonics necessary to synthesize `samples`.
417
419
  Else, harmonics must be at least one and no larger than half of
418
420
  `samples`.
419
421
  The phasor coordinates of missing harmonics are zeroed
@@ -462,18 +464,15 @@ def phasor_to_signal(
462
464
  array([2.2, 2.486, 0.8566, -0.4365, 0.3938])
463
465
 
464
466
  """
467
+ if samples < 3:
468
+ raise ValueError(f'{samples=} < 3')
469
+
465
470
  mean = numpy.array(mean, ndmin=0, copy=True)
466
471
  real = numpy.array(real, ndmin=0, copy=True)
467
472
  imag = numpy.array(imag, ndmin=1, copy=True)
468
473
 
469
- if isinstance(harmonic, (int, numbers.Integral)) and harmonic == 0:
470
- # harmonics are expected in the first axes of real and imag
471
- samples_ = 2 * imag.shape[0]
472
- else:
473
- samples_ = samples
474
-
475
474
  harmonic_ = harmonic
476
- harmonic, has_harmonic_axis = parse_harmonic(harmonic, samples_)
475
+ harmonic, has_harmonic_axis = parse_harmonic(harmonic, samples // 2)
477
476
 
478
477
  if real.ndim == 1 and len(harmonic) > 1 and real.shape[0] == len(harmonic):
479
478
  # single axis contains harmonic
@@ -641,7 +640,7 @@ def lifetime_to_signal(
641
640
  if harmonic is None:
642
641
  harmonic = 'all'
643
642
  all_hamonics = harmonic == 'all'
644
- harmonic, _ = parse_harmonic(harmonic, samples)
643
+ harmonic, _ = parse_harmonic(harmonic, samples // 2)
645
644
 
646
645
  if samples < 16:
647
646
  raise ValueError(f'{samples=} < 16')
@@ -962,12 +961,13 @@ def phasor_calibrate(
962
961
  frequency: ArrayLike,
963
962
  lifetime: ArrayLike,
964
963
  *,
964
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
965
+ skip_axis: int | Sequence[int] | None = None,
965
966
  fraction: ArrayLike | None = None,
966
967
  preexponential: bool = False,
967
968
  unit_conversion: float = 1e-3,
968
969
  reverse: bool = False,
969
970
  method: Literal['mean', 'median'] = 'mean',
970
- skip_axis: int | Sequence[int] | None = None,
971
971
  ) -> tuple[NDArray[Any], NDArray[Any]]:
972
972
  """
973
973
  Return calibrated/referenced phasor coordinates.
@@ -992,10 +992,22 @@ def phasor_calibrate(
992
992
  Must be measured with the same instrument setting as the phasor
993
993
  coordinates to be calibrated.
994
994
  frequency : array_like
995
- Laser pulse or modulation frequency in MHz.
996
- A scalar or one-dimensional sequence.
995
+ Fundamental laser pulse or modulation frequency in MHz.
997
996
  lifetime : array_like
998
- Lifetime components in ns. Must be scalar or one dimensional.
997
+ Lifetime components in ns. Must be scalar or one-dimensional.
998
+ harmonic : int, sequence of int, or 'all', default: 1
999
+ Harmonics included in `real` and `imag`.
1000
+ If an integer, the harmonics at which `real` and `imag` were acquired
1001
+ or calculated.
1002
+ If a sequence, the harmonics included in the first axis of `real` and
1003
+ `imag`.
1004
+ If `'all'`, the first axis of `real` and `imag` contains lower
1005
+ harmonics.
1006
+ The default is the first harmonic (fundamental frequency).
1007
+ 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.
999
1011
  fraction : array_like, optional
1000
1012
  Fractional intensities or pre-exponential amplitudes of the lifetime
1001
1013
  components. Fractions are normalized to sum to 1.
@@ -1015,9 +1027,6 @@ def phasor_calibrate(
1015
1027
 
1016
1028
  - ``'mean'``: Arithmetic mean of phasor coordinates.
1017
1029
  - ``'median'``: Spatial median of phasor coordinates.
1018
- skip_axis : int or sequence of int, optional
1019
- Axes to be excluded during center calculation. If None, all
1020
- axes are considered.
1021
1030
 
1022
1031
  Returns
1023
1032
  -------
@@ -1031,6 +1040,7 @@ def phasor_calibrate(
1031
1040
  ValueError
1032
1041
  The array shapes of `real` and `imag`, or `reference_real` and
1033
1042
  `reference_imag` do not match.
1043
+ Number of harmonics does not match the first axis of `real` and `imag`.
1034
1044
 
1035
1045
  See Also
1036
1046
  --------
@@ -1113,6 +1123,22 @@ def phasor_calibrate(
1113
1123
  f'reference_real.shape={ref_re.shape} '
1114
1124
  f'!= reference_imag.shape{ref_im.shape}'
1115
1125
  )
1126
+
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
+ frequency = numpy.asarray(frequency)
1135
+ frequency = frequency * harmonic
1136
+
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
+
1116
1142
  measured_re, measured_im = phasor_center(
1117
1143
  reference_real, reference_imag, skip_axis=skip_axis, method=method
1118
1144
  )
@@ -1130,7 +1156,6 @@ def phasor_calibrate(
1130
1156
  if reverse:
1131
1157
  numpy.negative(phi_zero, out=phi_zero)
1132
1158
  numpy.reciprocal(mod_zero, out=mod_zero)
1133
- _, axis = _parse_skip_axis(skip_axis, re.ndim)
1134
1159
  if axis is not None:
1135
1160
  phi_zero = numpy.expand_dims(
1136
1161
  phi_zero,
@@ -2648,14 +2673,17 @@ def phasor_filter(
2648
2673
  imag: ArrayLike,
2649
2674
  /,
2650
2675
  *,
2651
- method: Literal['median'] = 'median',
2676
+ method: Literal['median', 'median_scipy'] = 'median',
2652
2677
  repeat: int = 1,
2678
+ size: int = 3,
2679
+ skip_axis: int | Sequence[int] | None = None,
2680
+ num_threads: int | None = None,
2653
2681
  **kwargs: Any,
2654
2682
  ) -> tuple[NDArray[Any], NDArray[Any]]:
2655
2683
  """Return filtered phasor coordinates.
2656
2684
 
2657
- By default, a median filter is applied to the real and imaginary
2658
- components of phasor coordinates once with a kernel size of 3
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
2659
2687
  multiplied by the number of dimensions of the input arrays.
2660
2688
 
2661
2689
  Parameters
@@ -2668,9 +2696,21 @@ def phasor_filter(
2668
2696
  Method used for filtering:
2669
2697
 
2670
2698
  - ``'median'``: Spatial median of phasor coordinates.
2699
+ - ``'median_scipy'``: Spatial median of phasor coordinates
2700
+ based on :py:func:`scipy.ndimage.median_filter`.
2671
2701
 
2672
2702
  repeat : int, optional
2673
2703
  Number of times to apply filter. The default is 1.
2704
+ size : int, optional
2705
+ Size of filter kernel. The default is 3.
2706
+ skip_axis : int or sequence of int, optional
2707
+ Axis or axes to skip filtering. By default all axes are filtered.
2708
+ num_threads : int, optional
2709
+ Number of OpenMP threads to use for parallelization.
2710
+ Applies to filtering in two dimensions with the `median` method only.
2711
+ By default, multi-threading is disabled.
2712
+ If zero, up to half of logical CPUs are used.
2713
+ OpenMP may not be available on all platforms.
2674
2714
  **kwargs
2675
2715
  Optional arguments passed to :py:func:`scipy.ndimage.median_filter`.
2676
2716
 
@@ -2685,26 +2725,33 @@ def phasor_filter(
2685
2725
  ------
2686
2726
  ValueError
2687
2727
  If the specified method is not supported.
2728
+ If `repeat` is less than 0.
2729
+ If `size` is less than 1.
2688
2730
  The array shapes of `real` and `imag` do not match.
2689
- If `repeat` is less than 1.
2690
2731
 
2691
2732
  Notes
2692
2733
  -----
2693
- For now, only the median filter method is implemented.
2694
2734
  Additional filtering methods may be added in the future.
2695
2735
 
2696
- The implementation of the median filter method is based on
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
2697
2740
  :py:func:`scipy.ndimage.median_filter`,
2698
2741
  which has undefined behavior if the input arrays contain `NaN` values.
2699
2742
  See `issue #87 <https://github.com/phasorpy/phasorpy/issues/87>`_.
2700
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.
2747
+
2701
2748
  Examples
2702
2749
  --------
2703
2750
  Apply three times a median filter with a kernel size of three:
2704
2751
 
2705
2752
  >>> phasor_filter(
2706
2753
  ... [[0, 0, 0], [5, 5, 5], [2, 2, 2]],
2707
- ... [[3, 3, 3], [6, 6, 6], [4, 4, 4]],
2754
+ ... [[3, 3, 3], [6, math.nan, 6], [4, 4, 4]],
2708
2755
  ... size=3,
2709
2756
  ... repeat=3,
2710
2757
  ... )
@@ -2712,25 +2759,42 @@ def phasor_filter(
2712
2759
  [2, 2, 2],
2713
2760
  [2, 2, 2]]),
2714
2761
  array([[3, 3, 3],
2715
- [4, 4, 4],
2762
+ [4, nan, 4],
2716
2763
  [4, 4, 4]]))
2717
2764
 
2718
2765
  """
2719
- methods = {'median': _median_filter}
2766
+ methods: dict[str, Callable[..., Any]] = {
2767
+ 'median': _median_filter,
2768
+ 'median_scipy': _median_filter_scipy,
2769
+ }
2720
2770
  if method not in methods:
2721
2771
  raise ValueError(
2722
- f"Method not supported, supported methods are: "
2772
+ f'Method not supported, supported methods are: '
2723
2773
  f"{', '.join(methods)}"
2724
2774
  )
2775
+ if repeat == 0 or size == 1:
2776
+ return numpy.asarray(real), numpy.asarray(imag)
2777
+ if repeat < 0:
2778
+ raise ValueError(f'{repeat=} < 0')
2779
+ if size < 1:
2780
+ raise ValueError(f'{size=} < 1')
2781
+
2725
2782
  real = numpy.asarray(real)
2726
2783
  imag = numpy.asarray(imag)
2727
2784
 
2728
2785
  if real.shape != imag.shape:
2729
2786
  raise ValueError(f'{real.shape=} != {imag.shape=}')
2730
- if repeat < 1:
2731
- raise ValueError(f'{repeat=} < 1')
2732
2787
 
2733
- return methods[method](real, imag, repeat, **kwargs)
2788
+ _, axes = _parse_skip_axis(skip_axis, real.ndim)
2789
+
2790
+ if 'axes' in kwargs and method == 'median_scipy':
2791
+ axes = kwargs.pop('axes')
2792
+ if method == 'median':
2793
+ kwargs['num_threads'] = num_threads
2794
+
2795
+ return methods[method]( # type: ignore[no-any-return]
2796
+ real, imag, axes, repeat=repeat, size=size, **kwargs
2797
+ )
2734
2798
 
2735
2799
 
2736
2800
  def phasor_threshold(
@@ -2973,7 +3037,7 @@ def phasor_center(
2973
3037
  }
2974
3038
  if method not in methods:
2975
3039
  raise ValueError(
2976
- f"Method not supported, supported methods are: "
3040
+ f'Method not supported, supported methods are: '
2977
3041
  f"{', '.join(methods)}"
2978
3042
  )
2979
3043
  real = numpy.asarray(real)
@@ -3024,18 +3088,18 @@ def _median(
3024
3088
  Parameters
3025
3089
  ----------
3026
3090
  real : ndarray
3027
- Real components of the phasor coordinates.
3091
+ Real components of phasor coordinates.
3028
3092
  imag : ndarray
3029
- Imaginary components of the phasor coordinates.
3093
+ Imaginary components of phasor coordinates.
3030
3094
  **kwargs
3031
3095
  Optional arguments passed to :py:func:`numpy.nanmedian`.
3032
3096
 
3033
3097
  Returns
3034
3098
  -------
3035
3099
  real_center : ndarray
3036
- Spatial median center for real coordinates.
3100
+ Spatial median center of real coordinates.
3037
3101
  imag_center : ndarray
3038
- Spatial median center for imaginary coordinates.
3102
+ Spatial median center of imaginary coordinates.
3039
3103
 
3040
3104
  Examples
3041
3105
  --------
@@ -3047,42 +3111,154 @@ def _median(
3047
3111
 
3048
3112
 
3049
3113
  def _median_filter(
3050
- real: ArrayLike,
3051
- imag: ArrayLike,
3114
+ real: NDArray[Any],
3115
+ imag: NDArray[Any],
3116
+ axes: Sequence[int],
3117
+ /,
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
3215
+
3216
+
3217
+ def _median_filter_scipy(
3218
+ real: NDArray[Any],
3219
+ imag: NDArray[Any],
3220
+ axes: Sequence[int],
3221
+ /,
3222
+ *,
3052
3223
  repeat: int = 1,
3053
- size: int | tuple[int] | None = 3,
3224
+ size: int = 3,
3054
3225
  **kwargs: Any,
3055
3226
  ) -> tuple[NDArray[Any], NDArray[Any]]:
3056
- """Return the phasor coordinates after applying a median filter.
3227
+ """Return median-filtered phasor coordinates.
3057
3228
 
3058
3229
  Convenience wrapper around :py:func:`scipy.ndimage.median_filter`.
3059
3230
 
3060
3231
  Parameters
3061
3232
  ----------
3062
3233
  real : ndarray
3063
- Real components of the phasor coordinates.
3234
+ Real components of phasor coordinates.
3064
3235
  imag : ndarray
3065
- Imaginary components of the phasor coordinates.
3236
+ Imaginary components of phasor coordinates.
3237
+ axes : sequence of int
3238
+ Axes along which to apply median filter.
3066
3239
  repeat : int, optional
3067
3240
  Number of times to apply filter. The default is 1.
3068
- size : int or tuple of int, optional
3069
- The size of the median filter kernel. Default is 3.
3241
+ size : int, optional
3242
+ Size of median filter kernel. The default is 3.
3070
3243
  **kwargs
3071
3244
  Optional arguments passed to :py:func:`scipy.ndimage.median_filter`.
3072
3245
 
3073
3246
  Returns
3074
3247
  -------
3075
3248
  real : ndarray
3076
- Filtered real component of phasor coordinates.
3249
+ Median-filtered real component of phasor coordinates.
3077
3250
  imag : ndarray
3078
- Filtered imaginary component of phasor coordinates.
3251
+ Median-filtered imaginary component of phasor coordinates.
3079
3252
 
3080
3253
  """
3081
3254
  from scipy.ndimage import median_filter
3082
3255
 
3256
+ real = numpy.asarray(real)
3257
+ imag = numpy.asarray(imag)
3258
+
3083
3259
  for _ in range(repeat):
3084
- real = median_filter(real, size=size, **kwargs)
3085
- imag = median_filter(imag, size=size, **kwargs)
3260
+ real = median_filter(real, size=size, axes=axes, **kwargs)
3261
+ imag = median_filter(imag, size=size, axes=axes, **kwargs)
3086
3262
 
3087
3263
  return numpy.asarray(real), numpy.asarray(imag)
3088
3264
 
@@ -3129,7 +3305,7 @@ def _parse_skip_axis(
3129
3305
  if not isinstance(skip_axis, Sequence):
3130
3306
  skip_axis = (skip_axis,)
3131
3307
  if any(i >= ndim or i < -ndim for i in skip_axis):
3132
- raise IndexError(f"skip_axis={skip_axis} out of range for {ndim=}")
3308
+ raise IndexError(f'skip_axis={skip_axis} out of range for {ndim=}')
3133
3309
  skip_axis = tuple(sorted(int(i % ndim) for i in skip_axis))
3134
3310
  other_axis = tuple(i for i in range(ndim) if i not in skip_axis)
3135
3311
  return skip_axis, other_axis