phasorpy 0.7__cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.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/cli.py ADDED
@@ -0,0 +1,160 @@
1
+ """PhasorPy package command line interface.
2
+
3
+ Invoke the command line application with::
4
+
5
+ $ python -m phasorpy --help
6
+
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__: list[str] = []
12
+
13
+ import os
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from ._typing import Iterable
18
+
19
+ import click
20
+
21
+ from . import __version__
22
+
23
+
24
+ @click.group(help='PhasorPy package command line interface.')
25
+ @click.version_option(version=__version__)
26
+ def main() -> int:
27
+ """PhasorPy command line interface."""
28
+ return 0
29
+
30
+
31
+ @main.command(help='Show runtime versions.')
32
+ @click.option(
33
+ '--verbose',
34
+ default=False,
35
+ is_flag=True,
36
+ type=click.BOOL,
37
+ help='Show module paths.',
38
+ )
39
+ def versions(verbose: bool) -> None:
40
+ """Versions command group."""
41
+ from .utils import versions
42
+
43
+ click.echo(versions(verbose=verbose))
44
+
45
+
46
+ @main.command(help='Fetch sample files from remote repositories.')
47
+ @click.argument('files', nargs=-1)
48
+ @click.option(
49
+ '--hideprogress',
50
+ default=False,
51
+ is_flag=True,
52
+ type=click.BOOL,
53
+ help='Hide progressbar.',
54
+ )
55
+ def fetch(files: Iterable[str], hideprogress: bool) -> None:
56
+ """Fetch command group."""
57
+ from . import datasets
58
+
59
+ files = datasets.fetch(
60
+ *files, return_scalar=False, progressbar=not hideprogress
61
+ )
62
+ click.echo(f'Cached at {os.path.commonpath(files)}')
63
+
64
+
65
+ @main.command(help='Start interactive FRET phasor plot.')
66
+ @click.option(
67
+ '--hide',
68
+ default=False,
69
+ is_flag=True,
70
+ type=click.BOOL,
71
+ help='Do not show interactive plot.',
72
+ )
73
+ def fret(hide: bool) -> None:
74
+ """FRET command group."""
75
+ from .plot import PhasorPlotFret
76
+
77
+ plot = PhasorPlotFret(
78
+ frequency=60.0,
79
+ donor_lifetime=4.2,
80
+ acceptor_lifetime=3.0,
81
+ fret_efficiency=0.5,
82
+ interactive=True,
83
+ )
84
+ if not hide:
85
+ plot.show()
86
+
87
+
88
+ @main.command(help='Start interactive lifetime plots.')
89
+ @click.argument(
90
+ 'number_lifetimes',
91
+ default=2,
92
+ type=click.IntRange(1, 5),
93
+ required=False,
94
+ # help='Number of preconfigured lifetimes.',
95
+ )
96
+ @click.option(
97
+ '-f',
98
+ '--frequency',
99
+ type=float,
100
+ required=False,
101
+ help='Laser/modulation frequency in MHz.',
102
+ )
103
+ @click.option(
104
+ '-l',
105
+ '--lifetime',
106
+ # default=(4.0, 1.0),
107
+ type=float,
108
+ multiple=True,
109
+ required=False,
110
+ help='Lifetime in ns.',
111
+ )
112
+ @click.option(
113
+ '-a',
114
+ '--fraction',
115
+ type=float,
116
+ multiple=True,
117
+ required=False,
118
+ help='Fractional intensity of lifetime.',
119
+ )
120
+ @click.option(
121
+ '--hide',
122
+ default=False,
123
+ is_flag=True,
124
+ type=click.BOOL,
125
+ help='Do not show interactive plot.',
126
+ )
127
+ def lifetime(
128
+ number_lifetimes: int,
129
+ frequency: float | None,
130
+ lifetime: tuple[float, ...],
131
+ fraction: tuple[float, ...],
132
+ hide: bool,
133
+ ) -> None:
134
+ """Lifetime command group."""
135
+ from .lifetime import phasor_semicircle, phasor_to_normal_lifetime
136
+ from .plot import LifetimePlots
137
+
138
+ if not lifetime:
139
+ if number_lifetimes == 2:
140
+ lifetime = (4.0, 1.0)
141
+ else:
142
+ real, imag = phasor_semicircle(number_lifetimes + 2)
143
+ lifetime = phasor_to_normal_lifetime(
144
+ real[1:-1], imag[1:-1], frequency if frequency else 80.0
145
+ ) # type: ignore[assignment]
146
+
147
+ plot = LifetimePlots(
148
+ frequency,
149
+ lifetime,
150
+ fraction if len(fraction) > 0 else None,
151
+ interactive=True,
152
+ )
153
+ if not hide:
154
+ plot.show()
155
+
156
+
157
+ if __name__ == '__main__':
158
+ import sys
159
+
160
+ sys.exit(main()) # pylint: disable=no-value-for-parameter
phasorpy/cluster.py ADDED
@@ -0,0 +1,200 @@
1
+ """Cluster phasor coordinates.
2
+
3
+ The `phasorpy.cluster` module provides functions to:
4
+
5
+ - fit elliptic clusters to phasor coordinates using a
6
+ Gaussian Mixture Model (GMM):
7
+
8
+ - :py:func:`phasor_cluster_gmm`
9
+
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __all__ = ['phasor_cluster_gmm']
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from ._typing import Any, ArrayLike, Literal
20
+
21
+ import math
22
+
23
+ import numpy
24
+
25
+
26
+ def phasor_cluster_gmm(
27
+ real: ArrayLike,
28
+ imag: ArrayLike,
29
+ /,
30
+ *,
31
+ sigma: float = 2.0,
32
+ clusters: int = 1,
33
+ sort: Literal['polar', 'phasor', 'area'] | None = None,
34
+ **kwargs: Any,
35
+ ) -> tuple[
36
+ tuple[float, ...],
37
+ tuple[float, ...],
38
+ tuple[float, ...],
39
+ tuple[float, ...],
40
+ tuple[float, ...],
41
+ ]:
42
+ """Return elliptic clusters in phasor coordinates using GMM.
43
+
44
+ Fit a Gaussian Mixture Model (GMM) to the provided phasor coordinates and
45
+ extract the parameters of ellipses that represent each cluster according
46
+ to [1]_.
47
+
48
+ Parameters
49
+ ----------
50
+ real : array_like
51
+ Real component of phasor coordinates.
52
+ imag : array_like
53
+ Imaginary component of phasor coordinates.
54
+ sigma : float, optional
55
+ Scaling factor for radii of major and minor axes.
56
+ Defaults to 2.0, which corresponds to the scaling of eigenvalues for
57
+ a 95% confidence ellipse.
58
+ clusters : int, optional
59
+ Number of Gaussian distributions to fit to phasor coordinates.
60
+ Defaults to 1.
61
+ sort : {'polar', 'phasor', 'area'}, optional
62
+ Sorting method for output clusters. Defaults to 'polar'.
63
+
64
+ - 'polar': Sort by polar coordinates (phase, then modulation).
65
+ - 'phasor': Sort by phasor coordinates (real, then imaginary).
66
+ - 'area': Sort by inverse area of ellipse (-major * minor).
67
+
68
+ **kwargs
69
+ Additional keyword arguments passed to
70
+ :py:class:`sklearn.mixture.GaussianMixture`.
71
+
72
+ Common options include:
73
+
74
+ - covariance_type : {'full', 'tied', 'diag', 'spherical'}
75
+ - max_iter : int, maximum number of EM iterations
76
+ - random_state : int, for reproducible results
77
+
78
+ Returns
79
+ -------
80
+ center_real : tuple of float
81
+ Real component of ellipse centers.
82
+ center_imag : tuple of float
83
+ Imaginary component of ellipse centers.
84
+ radius_major : tuple of float
85
+ Major radii of ellipses.
86
+ radius_minor : tuple of float
87
+ Minor radii of ellipses.
88
+ angle : tuple of float
89
+ Rotation angles of major axes in radians, within range [0, pi].
90
+
91
+ References
92
+ ----------
93
+ .. [1] Vallmitjana A, Torrado B, and Gratton E.
94
+ `Phasor-based image segmentation: machine learning clustering techniques
95
+ <https://doi.org/10.1364/BOE.422766>`_.
96
+ *Biomed Opt Express*, 12(6): 3410-3422 (2021)
97
+
98
+ Examples
99
+ --------
100
+ Recover the clusters from a synthetic distribution of phasor coordinates
101
+ with two clusters:
102
+
103
+ >>> real1, imag1 = numpy.random.multivariate_normal(
104
+ ... [0.2, 0.3], [[3e-3, 1e-3], [1e-3, 2e-3]], 100
105
+ ... ).T
106
+ >>> real2, imag2 = numpy.random.multivariate_normal(
107
+ ... [0.4, 0.5], [[2e-3, -1e-3], [-1e-3, 3e-3]], 100
108
+ ... ).T
109
+ >>> real = numpy.concatenate([real1, real2])
110
+ >>> imag = numpy.concatenate([imag1, imag2])
111
+ >>> center_real, center_imag, radius_major, radius_minor, angle = (
112
+ ... phasor_cluster_gmm(real, imag, clusters=2)
113
+ ... )
114
+ >>> center_real # doctest: +SKIP
115
+ (0.2, 0.4)
116
+
117
+ """
118
+ from sklearn.mixture import GaussianMixture
119
+
120
+ coords = numpy.stack([real, imag], axis=-1).reshape(-1, 2)
121
+
122
+ valid_data = ~numpy.isnan(coords).any(axis=1)
123
+ coords = coords[valid_data]
124
+
125
+ kwargs.pop('n_components', None)
126
+
127
+ gmm = GaussianMixture(n_components=clusters, **kwargs)
128
+ gmm.fit(coords)
129
+
130
+ center_real = []
131
+ center_imag = []
132
+ radius_major = []
133
+ radius_minor = []
134
+ angle = []
135
+
136
+ for i in range(clusters):
137
+ center_real.append(float(gmm.means_[i, 0]))
138
+ center_imag.append(float(gmm.means_[i, 1]))
139
+
140
+ if gmm.covariance_type == 'full':
141
+ cov = gmm.covariances_[i]
142
+ elif gmm.covariance_type == 'tied':
143
+ cov = gmm.covariances_
144
+ elif gmm.covariance_type == 'diag':
145
+ cov = numpy.diag(gmm.covariances_[i])
146
+ else: # 'spherical'
147
+ cov = numpy.eye(2) * gmm.covariances_[i]
148
+
149
+ eigenvalues, eigenvectors = numpy.linalg.eigh(cov[:2, :2])
150
+
151
+ idx = eigenvalues.argsort()[::-1]
152
+ eigenvalues = eigenvalues[idx]
153
+ eigenvectors = eigenvectors[:, idx]
154
+
155
+ major_vector = eigenvectors[:, 0]
156
+ current_angle = math.atan2(major_vector[1], major_vector[0])
157
+
158
+ if current_angle < 0:
159
+ current_angle += math.pi
160
+
161
+ angle.append(float(current_angle))
162
+
163
+ radius_major.append(sigma * math.sqrt(2 * eigenvalues[0]))
164
+ radius_minor.append(sigma * math.sqrt(2 * eigenvalues[1]))
165
+
166
+ if clusters == 1:
167
+ argsort = [0]
168
+ else:
169
+ if sort is None or sort == 'polar':
170
+
171
+ def sort_key(i: int) -> Any:
172
+ return (
173
+ math.atan2(center_imag[i], center_real[i]),
174
+ math.hypot(center_real[i], center_imag[i]),
175
+ )
176
+
177
+ elif sort == 'phasor':
178
+
179
+ def sort_key(i: int) -> Any:
180
+ return center_imag[i], center_real[i]
181
+
182
+ elif sort == 'area':
183
+
184
+ def sort_key(i: int) -> Any:
185
+ return -radius_major[i] * radius_minor[i]
186
+
187
+ else:
188
+ raise ValueError(
189
+ f"invalid {sort=!r} != 'phasor', 'polar', or 'area'"
190
+ )
191
+
192
+ argsort = sorted(range(len(center_real)), key=sort_key)
193
+
194
+ return (
195
+ tuple(center_real[i] for i in argsort),
196
+ tuple(center_imag[i] for i in argsort),
197
+ tuple(radius_major[i] for i in argsort),
198
+ tuple(radius_minor[i] for i in argsort),
199
+ tuple(angle[i] for i in argsort),
200
+ )