phasorpy 0.4__cp311-cp311-win_amd64.whl → 0.6__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
@@ -17,6 +17,7 @@ The ``phasorpy.phasor`` module provides functions to:
17
17
  - :py:func:`phasor_from_lifetime`
18
18
  - :py:func:`phasor_from_apparent_lifetime`
19
19
  - :py:func:`phasor_to_apparent_lifetime`
20
+ - :py:func:`phasor_to_normal_lifetime`
20
21
 
21
22
  - convert to and from polar coordinates (phase and modulation):
22
23
 
@@ -59,15 +60,20 @@ The ``phasorpy.phasor`` module provides functions to:
59
60
  - :py:func:`lifetime_fraction_from_amplitude`
60
61
  - :py:func:`lifetime_fraction_to_amplitude`
61
62
 
62
- - calculate phasor coordinates on semicircle at other harmonics:
63
+ - calculate phasor coordinates on universal semicircle at other harmonics:
63
64
 
64
65
  - :py:func:`phasor_at_harmonic`
65
66
 
66
67
  - filter phasor coordinates:
67
68
 
68
69
  - :py:func:`phasor_filter_median`
70
+ - :py:func:`phasor_filter_pawflim`
69
71
  - :py:func:`phasor_threshold`
70
72
 
73
+ - find nearest neighbor phasor coordinates from another set of phasor coordinates:
74
+
75
+ - :py:func:`phasor_nearest_neighbor`
76
+
71
77
  """
72
78
 
73
79
  from __future__ import annotations
@@ -83,6 +89,7 @@ __all__ = [
83
89
  'phasor_center',
84
90
  'phasor_divide',
85
91
  'phasor_filter_median',
92
+ 'phasor_filter_pawflim',
86
93
  'phasor_from_apparent_lifetime',
87
94
  'phasor_from_fret_acceptor',
88
95
  'phasor_from_fret_donor',
@@ -90,11 +97,14 @@ __all__ = [
90
97
  'phasor_from_polar',
91
98
  'phasor_from_signal',
92
99
  'phasor_multiply',
100
+ 'phasor_nearest_neighbor',
93
101
  'phasor_normalize',
94
102
  'phasor_semicircle',
103
+ 'phasor_semicircle_intersect',
95
104
  'phasor_threshold',
96
105
  'phasor_to_apparent_lifetime',
97
106
  'phasor_to_complex',
107
+ 'phasor_to_normal_lifetime',
98
108
  'phasor_to_polar',
99
109
  'phasor_to_principal_plane',
100
110
  'phasor_to_signal',
@@ -123,7 +133,9 @@ import numpy
123
133
 
124
134
  from ._phasorpy import (
125
135
  _gaussian_signal,
136
+ _intersect_semicircle_line,
126
137
  _median_filter_2d,
138
+ _nearest_neighbor_2d,
127
139
  _phasor_at_harmonic,
128
140
  _phasor_divide,
129
141
  _phasor_from_apparent_lifetime,
@@ -140,6 +152,7 @@ from ._phasorpy import (
140
152
  _phasor_threshold_nan,
141
153
  _phasor_threshold_open,
142
154
  _phasor_to_apparent_lifetime,
155
+ _phasor_to_normal_lifetime,
143
156
  _phasor_to_polar,
144
157
  _phasor_transform,
145
158
  _phasor_transform_const,
@@ -149,7 +162,7 @@ from ._phasorpy import (
149
162
  _polar_from_single_lifetime,
150
163
  _polar_to_apparent_lifetime,
151
164
  )
152
- from ._utils import parse_harmonic, parse_signal_axis
165
+ from ._utils import parse_harmonic, parse_signal_axis, parse_skip_axis
153
166
  from .utils import number_threads
154
167
 
155
168
 
@@ -502,7 +515,8 @@ def phasor_to_signal(
502
515
  else:
503
516
  keepdims = mean.ndim > 0 or real.ndim > 0
504
517
 
505
- mean, real = numpy.atleast_1d(mean, real)
518
+ mean = numpy.asarray(numpy.atleast_1d(mean))
519
+ real = numpy.asarray(numpy.atleast_1d(real))
506
520
 
507
521
  if real.dtype.kind != 'f' or imag.dtype.kind != 'f':
508
522
  raise ValueError(f'{real.dtype=} or {imag.dtype=} not floating point')
@@ -595,7 +609,7 @@ def lifetime_to_signal(
595
609
  or `1` to synthesize a homodyne signal.
596
610
  zero_phase : float, optional
597
611
  Position of instrument response function in radians.
598
- Must be in range 0.0 to :math:`\pi`. The default is the 8th sample.
612
+ Must be in range [0, pi]. The default is the 8th sample.
599
613
  zero_stdev : float, optional
600
614
  Standard deviation of instrument response function in radians.
601
615
  Must be at least 1.5 samples and no more than one tenth of samples
@@ -671,7 +685,7 @@ def lifetime_to_signal(
671
685
  mean = 1.0
672
686
  mean = numpy.asarray(mean)
673
687
  mean -= background
674
- if numpy.any(mean <= 0.0):
688
+ if numpy.any(mean < 0.0):
675
689
  raise ValueError('mean - background must not be less than zero')
676
690
 
677
691
  scale = samples / (2.0 * math.pi)
@@ -683,7 +697,7 @@ def lifetime_to_signal(
683
697
  stdev = zero_stdev * scale # in sample units
684
698
 
685
699
  if zero_phase < 0 or zero_phase > 2.0 * math.pi:
686
- raise ValueError(f'{zero_phase=} out of range [0 .. 2 pi]')
700
+ raise ValueError(f'{zero_phase=} out of range [0, 2 pi]')
687
701
  if stdev < 1.5:
688
702
  raise ValueError(
689
703
  f'{zero_stdev=} < {1.5 / scale} cannot be sampled sufficiently'
@@ -749,9 +763,9 @@ def phasor_semicircle(
749
763
  Returns
750
764
  -------
751
765
  real : ndarray
752
- Real component of semicircle phasor coordinates.
766
+ Real component of phasor coordinates on universal semicircle.
753
767
  imag : ndarray
754
- Imaginary component of semicircle phasor coordinates.
768
+ Imaginary component of phasor coordinates on universal semicircle.
755
769
 
756
770
  Raises
757
771
  ------
@@ -791,6 +805,65 @@ def phasor_semicircle(
791
805
  return real, imag
792
806
 
793
807
 
808
+ def phasor_semicircle_intersect(
809
+ real0: ArrayLike,
810
+ imag0: ArrayLike,
811
+ real1: ArrayLike,
812
+ imag1: ArrayLike,
813
+ /,
814
+ **kwargs: Any,
815
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], NDArray[Any]]:
816
+ """Return intersection of line through phasors with universal semicircle.
817
+
818
+ Return the phasor coordinates of two intersections of the universal
819
+ semicircle with the line between two phasor coordinates.
820
+ Return NaN if the line does not intersect the semicircle.
821
+
822
+ Parameters
823
+ ----------
824
+ real0 : array_like
825
+ Real component of first set of phasor coordinates.
826
+ imag0 : array_like
827
+ Imaginary component of first set of phasor coordinates.
828
+ real1 : array_like
829
+ Real component of second set of phasor coordinates.
830
+ imag1 : array_like
831
+ Imaginary component of second set of phasor coordinates.
832
+ **kwargs
833
+ Optional `arguments passed to numpy universal functions
834
+ <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
835
+
836
+ Returns
837
+ -------
838
+ real0 : ndarray
839
+ Real component of first intersect of phasors with semicircle.
840
+ imag0 : ndarray
841
+ Imaginary component of first intersect of phasors with semicircle.
842
+ real1 : ndarray
843
+ Real component of second intersect of phasors with semicircle.
844
+ imag1 : ndarray
845
+ Imaginary component of second intersect of phasors with semicircle.
846
+
847
+ Examples
848
+ --------
849
+ Calculate two intersects of a line through two phasor coordinates
850
+ with the universal semicircle:
851
+
852
+ >>> phasor_semicircle_intersect(0.2, 0.25, 0.6, 0.25) # doctest: +NUMBER
853
+ (0.066, 0.25, 0.933, 0.25)
854
+
855
+ The line between two phasor coordinates may not intersect the semicircle
856
+ at two points:
857
+
858
+ >>> phasor_semicircle_intersect(0.2, 0.0, 0.6, 0.25) # doctest: +NUMBER
859
+ (nan, nan, 0.817, 0.386)
860
+
861
+ """
862
+ return _intersect_semicircle_line( # type: ignore[no-any-return]
863
+ real0, imag0, real1, imag1, **kwargs
864
+ )
865
+
866
+
794
867
  def phasor_to_complex(
795
868
  real: ArrayLike,
796
869
  imag: ArrayLike,
@@ -1050,9 +1123,7 @@ def phasor_normalize(
1050
1123
  else:
1051
1124
  real = numpy.array(real_unnormalized, dtype, copy=True)
1052
1125
  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
- )
1126
+ mean = numpy.array(mean_unnormalized, real.dtype, copy=True)
1056
1127
 
1057
1128
  with numpy.errstate(divide='ignore', invalid='ignore'):
1058
1129
  numpy.divide(real, mean, out=real)
@@ -1158,7 +1229,8 @@ def phasor_calibrate(
1158
1229
  ValueError
1159
1230
  The array shapes of `real` and `imag`, or `reference_real` and
1160
1231
  `reference_imag` do not match.
1161
- Number of harmonics does not match the first axis of `real` and `imag`.
1232
+ Number of harmonics or frequencies does not match the first axis
1233
+ of `real` and `imag`.
1162
1234
 
1163
1235
  See Also
1164
1236
  --------
@@ -1257,13 +1329,24 @@ def phasor_calibrate(
1257
1329
  ),
1258
1330
  )
1259
1331
 
1332
+ frequency = numpy.asarray(frequency)
1333
+ frequency = frequency * harmonic
1334
+
1260
1335
  if has_harmonic_axis:
1261
1336
  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)=}')
1337
+ raise ValueError(
1338
+ f'{real.shape=} != {len(frequency)} frequencies or harmonics'
1339
+ )
1340
+ if real.shape[0] != len(frequency):
1341
+ raise ValueError(
1342
+ f'{real.shape[0]=} != {len(frequency)} '
1343
+ 'frequencies or harmonics'
1344
+ )
1345
+ if reference_real.shape[0] != len(frequency):
1346
+ raise ValueError(
1347
+ f'{reference_real.shape[0]=} != {len(frequency)} '
1348
+ 'frequencies or harmonics'
1349
+ )
1267
1350
  if reference_mean.shape != reference_real.shape[1:]:
1268
1351
  raise ValueError(
1269
1352
  f'{reference_mean.shape=} != {reference_real.shape[1:]=}'
@@ -1275,9 +1358,6 @@ def phasor_calibrate(
1275
1358
  f'{reference_mean.shape=} does not have harmonic axis'
1276
1359
  )
1277
1360
 
1278
- frequency = numpy.asarray(frequency)
1279
- frequency = frequency * harmonic
1280
-
1281
1361
  _, measured_re, measured_im = phasor_center(
1282
1362
  reference_mean,
1283
1363
  reference_real,
@@ -1295,6 +1375,24 @@ def phasor_calibrate(
1295
1375
  unit_conversion=unit_conversion,
1296
1376
  )
1297
1377
 
1378
+ skip_axis, axis = parse_skip_axis(
1379
+ skip_axis, real.ndim - int(has_harmonic_axis), has_harmonic_axis
1380
+ )
1381
+
1382
+ if has_harmonic_axis and any(skip_axis):
1383
+ known_re = numpy.expand_dims(
1384
+ known_re, tuple(range(1, measured_re.ndim))
1385
+ )
1386
+ known_re = numpy.broadcast_to(
1387
+ known_re, (len(frequency), *measured_re.shape[1:])
1388
+ )
1389
+ known_im = numpy.expand_dims(
1390
+ known_im, tuple(range(1, measured_im.ndim))
1391
+ )
1392
+ known_im = numpy.broadcast_to(
1393
+ known_im, (len(frequency), *measured_im.shape[1:])
1394
+ )
1395
+
1298
1396
  phi_zero, mod_zero = polar_from_reference_phasor(
1299
1397
  measured_re, measured_im, known_re, known_im
1300
1398
  )
@@ -1303,9 +1401,6 @@ def phasor_calibrate(
1303
1401
  if reverse:
1304
1402
  numpy.negative(phi_zero, out=phi_zero)
1305
1403
  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
- )
1309
1404
  if axis is not None:
1310
1405
  phi_zero = numpy.expand_dims(phi_zero, axis=axis)
1311
1406
  mod_zero = numpy.expand_dims(mod_zero, axis=axis)
@@ -1552,6 +1647,7 @@ def phasor_to_polar(
1552
1647
  See Also
1553
1648
  --------
1554
1649
  phasorpy.phasor.phasor_from_polar
1650
+ :ref:`sphx_glr_tutorials_phasorpy_lifetime_geometry.py`
1555
1651
 
1556
1652
  Examples
1557
1653
  --------
@@ -1659,6 +1755,7 @@ def phasor_to_apparent_lifetime(
1659
1755
  See Also
1660
1756
  --------
1661
1757
  phasorpy.phasor.phasor_from_apparent_lifetime
1758
+ :ref:`sphx_glr_tutorials_phasorpy_lifetime_geometry.py`
1662
1759
 
1663
1760
  Notes
1664
1761
  -----
@@ -1794,6 +1891,86 @@ def phasor_from_apparent_lifetime(
1794
1891
  )
1795
1892
 
1796
1893
 
1894
+ def phasor_to_normal_lifetime(
1895
+ real: ArrayLike,
1896
+ imag: ArrayLike,
1897
+ /,
1898
+ frequency: ArrayLike,
1899
+ *,
1900
+ unit_conversion: float = 1e-3,
1901
+ **kwargs: Any,
1902
+ ) -> NDArray[Any]:
1903
+ r"""Return normal lifetimes from phasor coordinates.
1904
+
1905
+ The normal lifetime of phasor coordinates represents the single lifetime
1906
+ equivalent corresponding to the perpendicular projection of the coordinates
1907
+ onto the universal semicircle, as defined in [3]_.
1908
+
1909
+ Parameters
1910
+ ----------
1911
+ real : array_like
1912
+ Real component of phasor coordinates.
1913
+ imag : array_like
1914
+ Imaginary component of phasor coordinates.
1915
+ frequency : array_like
1916
+ Laser pulse or modulation frequency in MHz.
1917
+ unit_conversion : float, optional
1918
+ Product of `frequency` and returned `lifetime` units' prefix factors.
1919
+ The default is 1e-3 for MHz and ns, or Hz and ms.
1920
+ Use 1.0 for Hz and s.
1921
+ **kwargs
1922
+ Optional `arguments passed to numpy universal functions
1923
+ <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
1924
+
1925
+ Returns
1926
+ -------
1927
+ normal_lifetime : ndarray
1928
+ Normal lifetime from of phasor coordinates.
1929
+
1930
+ See Also
1931
+ --------
1932
+ :ref:`sphx_glr_tutorials_phasorpy_lifetime_geometry.py`
1933
+
1934
+ Notes
1935
+ -----
1936
+ The phasor coordinates `real` (:math:`G`) and `imag` (:math:`S`)
1937
+ are converted to normal lifetimes `normal_lifetime` (:math:`\tau_{N}`)
1938
+ at frequency :math:`f` according to:
1939
+
1940
+ .. math::
1941
+
1942
+ \omega &= 2 \pi f
1943
+
1944
+ G_{N} &= 0.5 \cdot (1 + \cos{\arctan{\frac{S}{G - 0.5}}})
1945
+
1946
+ \tau_{N} &= \sqrt{\frac{1 - G_{N}}{\omega^{2} \cdot G_{N}}}
1947
+
1948
+ References
1949
+ ----------
1950
+
1951
+ .. [3] Silberberg M, and Grecco H. `pawFLIM: reducing bias and
1952
+ uncertainty to enable lower photon count in FLIM experiments
1953
+ <https://doi.org/10.1088/2050-6120/aa72ab>`_.
1954
+ *Methods Appl Fluoresc*, 5(2): 024016 (2017)
1955
+
1956
+ Examples
1957
+ --------
1958
+ The normal lifetimes of phasor coordinates with a real component of 0.5
1959
+ are independent of the imaginary component:
1960
+
1961
+ >>> phasor_to_normal_lifetime(
1962
+ ... 0.5, [0.5, 0.45], frequency=80
1963
+ ... ) # doctest: +NUMBER
1964
+ array([1.989, 1.989])
1965
+
1966
+ """
1967
+ omega = numpy.array(frequency, dtype=numpy.float64) # makes copy
1968
+ omega *= math.pi * 2.0 * unit_conversion
1969
+ return _phasor_to_normal_lifetime( # type: ignore[no-any-return]
1970
+ real, imag, omega, **kwargs
1971
+ )
1972
+
1973
+
1797
1974
  def lifetime_to_frequency(
1798
1975
  lifetime: ArrayLike,
1799
1976
  *,
@@ -2113,6 +2290,11 @@ def phasor_from_lifetime(
2113
2290
  ValueError
2114
2291
  Input arrays exceed their allowed dimensionality or do not match.
2115
2292
 
2293
+ See Also
2294
+ --------
2295
+ :ref:`sphx_glr_tutorials_api_phasorpy_phasor_from_lifetime.py`
2296
+ :ref:`sphx_glr_tutorials_phasorpy_lifetime_geometry.py`
2297
+
2116
2298
  Notes
2117
2299
  -----
2118
2300
  The phasor coordinates :math:`G` (`real`) and :math:`S` (`imag`) for
@@ -2433,7 +2615,7 @@ def phasor_from_fret_donor(
2433
2615
  donor_lifetime: ArrayLike,
2434
2616
  *,
2435
2617
  fret_efficiency: ArrayLike = 0.0,
2436
- donor_freting: ArrayLike = 1.0,
2618
+ donor_fretting: ArrayLike = 1.0,
2437
2619
  donor_background: ArrayLike = 0.0,
2438
2620
  background_real: ArrayLike = 0.0,
2439
2621
  background_imag: ArrayLike = 0.0,
@@ -2459,9 +2641,9 @@ def phasor_from_fret_donor(
2459
2641
  donor_lifetime : array_like
2460
2642
  Lifetime of donor without FRET in ns.
2461
2643
  fret_efficiency : array_like, optional, default 0
2462
- FRET efficiency in range [0..1].
2463
- donor_freting : array_like, optional, default 1
2464
- Fraction of donors participating in FRET. Range [0..1].
2644
+ FRET efficiency in range [0, 1].
2645
+ donor_fretting : array_like, optional, default 1
2646
+ Fraction of donors participating in FRET. Range [0, 1].
2465
2647
  donor_background : array_like, optional, default 0
2466
2648
  Weight of background fluorescence in donor channel
2467
2649
  relative to fluorescence of donor without FRET.
@@ -2492,6 +2674,7 @@ def phasor_from_fret_donor(
2492
2674
  --------
2493
2675
  phasorpy.phasor.phasor_from_fret_acceptor
2494
2676
  :ref:`sphx_glr_tutorials_api_phasorpy_fret.py`
2677
+ :ref:`sphx_glr_tutorials_applications_phasorpy_fret_efficiency.py`
2495
2678
 
2496
2679
  Examples
2497
2680
  --------
@@ -2502,7 +2685,7 @@ def phasor_from_fret_donor(
2502
2685
  ... frequency=80,
2503
2686
  ... donor_lifetime=4.2,
2504
2687
  ... fret_efficiency=[0.0, 0.3, 1.0],
2505
- ... donor_freting=0.9,
2688
+ ... donor_fretting=0.9,
2506
2689
  ... donor_background=0.1,
2507
2690
  ... background_real=0.11,
2508
2691
  ... background_imag=0.12,
@@ -2516,7 +2699,7 @@ def phasor_from_fret_donor(
2516
2699
  omega,
2517
2700
  donor_lifetime,
2518
2701
  fret_efficiency,
2519
- donor_freting,
2702
+ donor_fretting,
2520
2703
  donor_background,
2521
2704
  background_real,
2522
2705
  background_imag,
@@ -2530,7 +2713,7 @@ def phasor_from_fret_acceptor(
2530
2713
  acceptor_lifetime: ArrayLike,
2531
2714
  *,
2532
2715
  fret_efficiency: ArrayLike = 0.0,
2533
- donor_freting: ArrayLike = 1.0,
2716
+ donor_fretting: ArrayLike = 1.0,
2534
2717
  donor_bleedthrough: ArrayLike = 0.0,
2535
2718
  acceptor_bleedthrough: ArrayLike = 0.0,
2536
2719
  acceptor_background: ArrayLike = 0.0,
@@ -2563,9 +2746,9 @@ def phasor_from_fret_acceptor(
2563
2746
  acceptor_lifetime : array_like
2564
2747
  Lifetime of acceptor in ns.
2565
2748
  fret_efficiency : array_like, optional, default 0
2566
- FRET efficiency in range [0..1].
2567
- donor_freting : array_like, optional, default 1
2568
- Fraction of donors participating in FRET. Range [0..1].
2749
+ FRET efficiency in range [0, 1].
2750
+ donor_fretting : array_like, optional, default 1
2751
+ Fraction of donors participating in FRET. Range [0, 1].
2569
2752
  donor_bleedthrough : array_like, optional, default 0
2570
2753
  Weight of donor fluorescence in acceptor channel
2571
2754
  relative to fluorescence of fully sensitized acceptor.
@@ -2618,7 +2801,7 @@ def phasor_from_fret_acceptor(
2618
2801
  ... donor_lifetime=4.2,
2619
2802
  ... acceptor_lifetime=3.0,
2620
2803
  ... fret_efficiency=[0.0, 0.3, 1.0],
2621
- ... donor_freting=0.9,
2804
+ ... donor_fretting=0.9,
2622
2805
  ... donor_bleedthrough=0.1,
2623
2806
  ... acceptor_bleedthrough=0.1,
2624
2807
  ... acceptor_background=0.1,
@@ -2635,7 +2818,7 @@ def phasor_from_fret_acceptor(
2635
2818
  donor_lifetime,
2636
2819
  acceptor_lifetime,
2637
2820
  fret_efficiency,
2638
- donor_freting,
2821
+ donor_fretting,
2639
2822
  donor_bleedthrough,
2640
2823
  acceptor_bleedthrough,
2641
2824
  acceptor_background,
@@ -2720,7 +2903,7 @@ def phasor_to_principal_plane(
2720
2903
  References
2721
2904
  ----------
2722
2905
 
2723
- .. [1] Franssen WMJ, Vergeldt FJ, Bader AN, van Amerongen H, & Terenzi C.
2906
+ .. [1] Franssen WMJ, Vergeldt FJ, Bader AN, van Amerongen H, and Terenzi C.
2724
2907
  `Full-harmonics phasor analysis: unravelling multiexponential trends
2725
2908
  in magnetic resonance imaging data
2726
2909
  <https://doi.org/10.1021/acs.jpclett.0c02319>`_.
@@ -2932,7 +3115,7 @@ def phasor_filter_median(
2932
3115
  raise ValueError(f'{real.shape=} != {imag.shape=}')
2933
3116
 
2934
3117
  prepend_axis = mean.ndim + 1 == real.ndim
2935
- _, axes = _parse_skip_axis(skip_axis, mean.ndim, prepend_axis)
3118
+ _, axes = parse_skip_axis(skip_axis, mean.ndim, prepend_axis)
2936
3119
 
2937
3120
  # in case mean is also filtered
2938
3121
  # if prepend_axis:
@@ -3006,6 +3189,209 @@ def phasor_filter_median(
3006
3189
  return mean, real, imag
3007
3190
 
3008
3191
 
3192
+ def phasor_filter_pawflim(
3193
+ mean: ArrayLike,
3194
+ real: ArrayLike,
3195
+ imag: ArrayLike,
3196
+ /,
3197
+ *,
3198
+ sigma: float = 2.0,
3199
+ levels: int = 1,
3200
+ harmonic: Sequence[int] | None = None,
3201
+ skip_axis: int | Sequence[int] | None = None,
3202
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
3203
+ """Return pawFLIM wavelet-filtered phasor coordinates.
3204
+
3205
+ This function must only be used with calibrated, unprocessed phasor
3206
+ coordinates obtained from FLIM data. The coordinates must not be filtered,
3207
+ thresholded, or otherwise pre-processed.
3208
+
3209
+ The pawFLIM wavelet filter is described in [2]_.
3210
+
3211
+ Parameters
3212
+ ----------
3213
+ mean : array_like
3214
+ Intensity of phasor coordinates.
3215
+ real : array_like
3216
+ Real component of phasor coordinates to be filtered.
3217
+ Must have at least two harmonics in the first axis.
3218
+ imag : array_like
3219
+ Imaginary component of phasor coordinates to be filtered.
3220
+ Must have at least two harmonics in the first axis.
3221
+ sigma : float, optional
3222
+ Significance level to test difference between two phasors.
3223
+ Given in terms of the equivalent 1D standard deviations.
3224
+ sigma=2 corresponds to ~95% (or 5%) significance.
3225
+ levels : int, optional
3226
+ Number of levels for wavelet decomposition.
3227
+ Controls the maximum averaging area, which has a length of
3228
+ :math:`2^level`.
3229
+ harmonic : sequence of int or None, optional
3230
+ Harmonics included in first axis of `real` and `imag`.
3231
+ If None (default), the first axis of `real` and `imag` contains lower
3232
+ harmonics starting at and increasing by one.
3233
+ All harmonics must have a corresponding half or double harmonic.
3234
+ skip_axis : int or sequence of int, optional
3235
+ Axes in `mean` to exclude from filter.
3236
+ By default, all axes except harmonics are included.
3237
+
3238
+ Returns
3239
+ -------
3240
+ mean : ndarray
3241
+ Unchanged intensity of phasor coordinates.
3242
+ real : ndarray
3243
+ Filtered real component of phasor coordinates.
3244
+ imag : ndarray
3245
+ Filtered imaginary component of phasor coordinates.
3246
+
3247
+ Raises
3248
+ ------
3249
+ ValueError
3250
+ If `level` is less than 0.
3251
+ The array shapes of `mean`, `real`, and `imag` do not match.
3252
+ If `real` and `imag` have no harmonic axis.
3253
+ Number of harmonics in `harmonic` is less than 2 or does not match
3254
+ the first axis of `real` and `imag`.
3255
+ Not all harmonics in `harmonic` have a corresponding half
3256
+ or double harmonic.
3257
+
3258
+ References
3259
+ ----------
3260
+
3261
+ .. [2] Silberberg M, and Grecco H. `pawFLIM: reducing bias and
3262
+ uncertainty to enable lower photon count in FLIM experiments
3263
+ <https://doi.org/10.1088/2050-6120/aa72ab>`_.
3264
+ *Methods Appl Fluoresc*, 5(2): 024016 (2017)
3265
+
3266
+ Examples
3267
+ --------
3268
+ Apply a pawFLIM wavelet filter with four significance levels (sigma)
3269
+ and three decomposition levels:
3270
+
3271
+ >>> mean, real, imag = phasor_filter_pawflim(
3272
+ ... [[1, 1], [1, 1]],
3273
+ ... [[[0.5, 0.8], [0.5, 0.8]], [[0.2, 0.4], [0.2, 0.4]]],
3274
+ ... [[[0.5, 0.4], [0.5, 0.4]], [[0.4, 0.5], [0.4, 0.5]]],
3275
+ ... sigma=4,
3276
+ ... levels=3,
3277
+ ... harmonic=[1, 2],
3278
+ ... )
3279
+ >>> mean
3280
+ array([[1, 1],
3281
+ [1, 1]])
3282
+ >>> real
3283
+ array([[[0.65, 0.65],
3284
+ [0.65, 0.65]],
3285
+ [[0.3, 0.3],
3286
+ [0.3, 0.3]]])
3287
+ >>> imag
3288
+ array([[[0.45, 0.45],
3289
+ [0.45, 0.45]],
3290
+ [[0.45, 0.45],
3291
+ [0.45, 0.45]]])
3292
+
3293
+ """
3294
+ from pawflim import pawflim # type: ignore[import-untyped]
3295
+
3296
+ mean = numpy.asarray(mean)
3297
+ real = numpy.asarray(real)
3298
+ imag = numpy.asarray(imag)
3299
+
3300
+ if levels < 0:
3301
+ raise ValueError(f'{levels=} < 0')
3302
+ if levels == 0:
3303
+ return mean, real, imag
3304
+
3305
+ if mean.shape != real.shape[-mean.ndim if mean.ndim else 1 :]:
3306
+ raise ValueError(f'{mean.shape=} != {real.shape=}')
3307
+ if real.shape != imag.shape:
3308
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
3309
+
3310
+ has_harmonic_axis = mean.ndim + 1 == real.ndim
3311
+ if not has_harmonic_axis:
3312
+ raise ValueError('no harmonic axis')
3313
+ if harmonic is None:
3314
+ harmonics, _ = parse_harmonic('all', real.shape[0])
3315
+ else:
3316
+ harmonics, _ = parse_harmonic(harmonic, None)
3317
+ if len(harmonics) < 2:
3318
+ raise ValueError(
3319
+ 'at least two harmonics required, ' f'got {len(harmonics)}'
3320
+ )
3321
+ if len(harmonics) != real.shape[0]:
3322
+ raise ValueError(
3323
+ 'number of harmonics does not match first axis of real and imag'
3324
+ )
3325
+
3326
+ mean = numpy.asarray(numpy.nan_to_num(mean), dtype=float)
3327
+ real = numpy.asarray(numpy.nan_to_num(real * mean), dtype=float)
3328
+ imag = numpy.asarray(numpy.nan_to_num(imag * mean), dtype=float)
3329
+
3330
+ mean_expanded = numpy.broadcast_to(mean, real.shape).copy()
3331
+ original_mean_expanded = mean_expanded.copy()
3332
+ real_filtered = real.copy()
3333
+ imag_filtered = imag.copy()
3334
+
3335
+ _, axes = parse_skip_axis(skip_axis, mean.ndim, True)
3336
+
3337
+ for index in numpy.ndindex(
3338
+ *(
3339
+ real.shape[ax]
3340
+ for ax in range(real.ndim)
3341
+ if ax not in axes and ax != 0
3342
+ )
3343
+ ):
3344
+ index_list: list[int | slice] = list(index)
3345
+ for ax in axes:
3346
+ index_list = index_list[:ax] + [slice(None)] + index_list[ax:]
3347
+ full_index = tuple(index_list)
3348
+
3349
+ processed_harmonics = set()
3350
+
3351
+ for h in harmonics:
3352
+ if h in processed_harmonics and (
3353
+ h * 4 in harmonics or h * 2 not in harmonics
3354
+ ):
3355
+ continue
3356
+ if h * 2 not in harmonics:
3357
+ raise ValueError(
3358
+ f'harmonic {h} does not have a corresponding half '
3359
+ f'or double harmonic in {harmonics}'
3360
+ )
3361
+ n = harmonics.index(h)
3362
+ n2 = harmonics.index(h * 2)
3363
+
3364
+ complex_phasor = numpy.empty(
3365
+ (3, *original_mean_expanded[n][full_index].shape),
3366
+ dtype=complex,
3367
+ )
3368
+ complex_phasor[0] = original_mean_expanded[n][full_index]
3369
+ complex_phasor[1] = real[n][full_index] + 1j * imag[n][full_index]
3370
+ complex_phasor[2] = (
3371
+ real[n2][full_index] + 1j * imag[n2][full_index]
3372
+ )
3373
+
3374
+ complex_phasor = pawflim(
3375
+ complex_phasor, n_sigmas=sigma, levels=levels
3376
+ )
3377
+
3378
+ for i, idx in enumerate([n, n2]):
3379
+ if harmonics[idx] in processed_harmonics:
3380
+ continue
3381
+ mean_expanded[idx][full_index] = complex_phasor[0].real
3382
+ real_filtered[idx][full_index] = complex_phasor[i + 1].real
3383
+ imag_filtered[idx][full_index] = complex_phasor[i + 1].imag
3384
+
3385
+ processed_harmonics.add(h)
3386
+ processed_harmonics.add(h * 2)
3387
+
3388
+ with numpy.errstate(divide='ignore', invalid='ignore'):
3389
+ real = numpy.asarray(numpy.divide(real_filtered, mean_expanded))
3390
+ imag = numpy.asarray(numpy.divide(imag_filtered, mean_expanded))
3391
+
3392
+ return mean, real, imag
3393
+
3394
+
3009
3395
  def phasor_threshold(
3010
3396
  mean: ArrayLike,
3011
3397
  real: ArrayLike,
@@ -3224,6 +3610,154 @@ def phasor_threshold(
3224
3610
  return mean, real, imag
3225
3611
 
3226
3612
 
3613
+ def phasor_nearest_neighbor(
3614
+ real: ArrayLike,
3615
+ imag: ArrayLike,
3616
+ neighbor_real: ArrayLike,
3617
+ neighbor_imag: ArrayLike,
3618
+ /,
3619
+ *,
3620
+ values: ArrayLike | None = None,
3621
+ dtype: DTypeLike | None = None,
3622
+ distance_max: float | None = None,
3623
+ num_threads: int | None = None,
3624
+ ) -> NDArray[Any]:
3625
+ """Return indices or values of nearest neighbor from other coordinates.
3626
+
3627
+ For each phasor coordinate, find the nearest neighbor in another set of
3628
+ phasor coordinates and return its flat index. If more than one neighbor
3629
+ has the same distance, return the smallest index.
3630
+
3631
+ For phasor coordinates that are NaN, or have a distance to the nearest
3632
+ neighbor that is larger than `distance_max`, return an index of -1.
3633
+
3634
+ If `values` are provided, return the values corresponding to the nearest
3635
+ neighbor coordinates instead of indices. Return NaN values for indices
3636
+ that are -1.
3637
+
3638
+ This function does not support multi-harmonic, multi-channel, or
3639
+ multi-frequency phasor coordinates.
3640
+
3641
+ Parameters
3642
+ ----------
3643
+ real : array_like
3644
+ Real component of phasor coordinates.
3645
+ imag : array_like
3646
+ Imaginary component of phasor coordinates.
3647
+ neighbor_real : array_like
3648
+ Real component of neighbor phasor coordinates.
3649
+ neighbor_imag : array_like
3650
+ Imaginary component of neighbor phasor coordinates.
3651
+ values : array_like, optional
3652
+ Array of values corresponding to neighbor coordinates.
3653
+ If provided, return the values corresponding to the nearest
3654
+ neighbor coordinates.
3655
+ distance_max : float, optional
3656
+ Maximum Euclidean distance to consider a neighbor valid.
3657
+ By default, all neighbors are considered.
3658
+ dtype : dtype_like, optional
3659
+ Floating point data type used for calculation and output values.
3660
+ Either `float32` or `float64`. The default is `float64`.
3661
+ num_threads : int, optional
3662
+ Number of OpenMP threads to use for parallelization.
3663
+ By default, multi-threading is disabled.
3664
+ If zero, up to half of logical CPUs are used.
3665
+ OpenMP may not be available on all platforms.
3666
+
3667
+ Returns
3668
+ -------
3669
+ nearest : ndarray
3670
+ Flat indices (or the corresponding values if provided) of the nearest
3671
+ neighbor coordinates.
3672
+
3673
+ Raises
3674
+ ------
3675
+ ValueError
3676
+ If the shapes of `real`, and `imag` do not match.
3677
+ If the shapes of `neighbor_real` and `neighbor_imag` do not match.
3678
+ If the shapes of `values` and `neighbor_real` do not match.
3679
+ If `distance_max` is less than or equal to zero.
3680
+
3681
+ See Also
3682
+ --------
3683
+ :ref:`sphx_glr_tutorials_applications_phasorpy_fret_efficiency.py`
3684
+
3685
+ Notes
3686
+ -----
3687
+ This function uses linear search, which is inefficient for large
3688
+ number of coordinates or neighbors.
3689
+ ``scipy.spatial.KDTree.query()`` would be more efficient in those cases.
3690
+ However, KDTree is known to return non-deterministic results in case of
3691
+ multiple neighbors with the same distance.
3692
+
3693
+ Examples
3694
+ --------
3695
+ >>> phasor_nearest_neighbor(
3696
+ ... [0.1, 0.5, numpy.nan],
3697
+ ... [0.1, 0.5, numpy.nan],
3698
+ ... [0, 0.4],
3699
+ ... [0, 0.4],
3700
+ ... values=[10, 20],
3701
+ ... )
3702
+ array([10, 20, nan])
3703
+
3704
+ """
3705
+ dtype = numpy.dtype(dtype)
3706
+ if dtype.char not in {'f', 'd'}:
3707
+ raise ValueError(f'{dtype=} is not a floating point type')
3708
+
3709
+ real = numpy.ascontiguousarray(real, dtype=dtype)
3710
+ imag = numpy.ascontiguousarray(imag, dtype=dtype)
3711
+ neighbor_real = numpy.ascontiguousarray(neighbor_real, dtype=dtype)
3712
+ neighbor_imag = numpy.ascontiguousarray(neighbor_imag, dtype=dtype)
3713
+
3714
+ if real.shape != imag.shape:
3715
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
3716
+ if neighbor_real.shape != neighbor_imag.shape:
3717
+ raise ValueError(f'{neighbor_real.shape=} != {neighbor_imag.shape=}')
3718
+
3719
+ shape = real.shape
3720
+ real = real.ravel()
3721
+ imag = imag.ravel()
3722
+ neighbor_real = neighbor_real.ravel()
3723
+ neighbor_imag = neighbor_imag.ravel()
3724
+
3725
+ indices = numpy.empty(
3726
+ real.shape, numpy.min_scalar_type(-neighbor_real.size)
3727
+ )
3728
+
3729
+ if distance_max is None:
3730
+ distance_max = numpy.inf
3731
+ else:
3732
+ distance_max = float(distance_max)
3733
+ if distance_max <= 0:
3734
+ raise ValueError(f'{distance_max=} <= 0')
3735
+
3736
+ num_threads = number_threads(num_threads)
3737
+
3738
+ _nearest_neighbor_2d(
3739
+ indices,
3740
+ real,
3741
+ imag,
3742
+ neighbor_real,
3743
+ neighbor_imag,
3744
+ distance_max,
3745
+ num_threads,
3746
+ )
3747
+
3748
+ if values is None:
3749
+ return numpy.asarray(indices.reshape(shape))
3750
+
3751
+ values = numpy.ascontiguousarray(values, dtype=dtype).ravel()
3752
+ if values.shape != neighbor_real.shape:
3753
+ raise ValueError(f'{values.shape=} != {neighbor_real.shape=}')
3754
+
3755
+ nearest_values = values[indices]
3756
+ nearest_values[indices == -1] = numpy.nan
3757
+
3758
+ return numpy.asarray(nearest_values.reshape(shape))
3759
+
3760
+
3227
3761
  def phasor_center(
3228
3762
  mean: ArrayLike,
3229
3763
  real: ArrayLike,
@@ -3314,7 +3848,7 @@ def phasor_center(
3314
3848
  raise ValueError(f'{mean.shape=} != {real.shape=}')
3315
3849
 
3316
3850
  prepend_axis = mean.ndim + 1 == real.ndim
3317
- _, axis = _parse_skip_axis(skip_axis, mean.ndim, prepend_axis)
3851
+ _, axis = parse_skip_axis(skip_axis, mean.ndim, prepend_axis)
3318
3852
  if prepend_axis:
3319
3853
  mean = numpy.expand_dims(mean, axis=0)
3320
3854
 
@@ -3358,62 +3892,3 @@ def _median(
3358
3892
  numpy.nanmedian(real, **kwargs),
3359
3893
  numpy.nanmedian(imag, **kwargs),
3360
3894
  )
3361
-
3362
-
3363
- def _parse_skip_axis(
3364
- skip_axis: int | Sequence[int] | None,
3365
- /,
3366
- ndim: int,
3367
- prepend_axis: bool = False,
3368
- ) -> tuple[tuple[int, ...], tuple[int, ...]]:
3369
- """Return axes to skip and not to skip.
3370
-
3371
- This helper function is used to validate and parse `skip_axis`
3372
- parameters.
3373
-
3374
- Parameters
3375
- ----------
3376
- skip_axis : int or sequence of int, optional
3377
- Axes to skip. If None, no axes are skipped.
3378
- ndim : int
3379
- Dimensionality of array in which to skip axes.
3380
- prepend_axis : bool, optional
3381
- Prepend one dimension and include in `skip_axis`.
3382
-
3383
- Returns
3384
- -------
3385
- skip_axis
3386
- Ordered, positive values of `skip_axis`.
3387
- other_axis
3388
- Axes indices not included in `skip_axis`.
3389
-
3390
- Raises
3391
- ------
3392
- IndexError
3393
- If any `skip_axis` value is out of bounds of `ndim`.
3394
-
3395
- Examples
3396
- --------
3397
- >>> _parse_skip_axis((1, -2), 5)
3398
- ((1, 3), (0, 2, 4))
3399
-
3400
- >>> _parse_skip_axis((1, -2), 5, True)
3401
- ((0, 2, 4), (1, 3, 5))
3402
-
3403
- """
3404
- if ndim < 0:
3405
- raise ValueError(f'invalid {ndim=}')
3406
- if skip_axis is None:
3407
- if prepend_axis:
3408
- return (0,), tuple(range(1, ndim + 1))
3409
- return (), tuple(range(ndim))
3410
- if not isinstance(skip_axis, Sequence):
3411
- skip_axis = (skip_axis,)
3412
- if any(i >= ndim or i < -ndim for i in skip_axis):
3413
- raise IndexError(f'skip_axis={skip_axis} out of range for {ndim=}')
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
3418
- other_axis = tuple(i for i in range(ndim) if i not in skip_axis)
3419
- return tuple(skip_axis), other_axis