phasorpy 0.4__cp313-cp313-win_amd64.whl → 0.6__cp313-cp313-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/__init__.py +2 -3
- phasorpy/_phasorpy.cp313-win_amd64.pyd +0 -0
- phasorpy/_phasorpy.pyx +237 -51
- phasorpy/_utils.py +201 -7
- phasorpy/cli.py +58 -3
- phasorpy/cluster.py +206 -0
- phasorpy/color.py +16 -11
- phasorpy/components.py +240 -69
- phasorpy/conftest.py +2 -0
- phasorpy/cursors.py +9 -9
- phasorpy/datasets.py +129 -51
- phasorpy/experimental.py +312 -0
- phasorpy/io/__init__.py +137 -0
- phasorpy/io/_flimlabs.py +350 -0
- phasorpy/io/_leica.py +329 -0
- phasorpy/io/_ometiff.py +445 -0
- phasorpy/io/_other.py +782 -0
- phasorpy/io/_simfcs.py +627 -0
- phasorpy/phasor.py +572 -97
- phasorpy/plot/__init__.py +27 -0
- phasorpy/plot/_functions.py +717 -0
- phasorpy/plot/_lifetime_plots.py +553 -0
- phasorpy/plot/_phasorplot.py +1119 -0
- phasorpy/plot/_phasorplot_fret.py +559 -0
- phasorpy/utils.py +90 -297
- {phasorpy-0.4.dist-info → phasorpy-0.6.dist-info}/METADATA +11 -16
- phasorpy-0.6.dist-info/RECORD +34 -0
- {phasorpy-0.4.dist-info → phasorpy-0.6.dist-info}/WHEEL +1 -1
- phasorpy/_io.py +0 -2431
- phasorpy/io.py +0 -5
- phasorpy/plot.py +0 -2094
- phasorpy/version.py +0 -72
- phasorpy-0.4.dist-info/RECORD +0 -25
- {phasorpy-0.4.dist-info → phasorpy-0.6.dist-info}/entry_points.txt +0 -0
- {phasorpy-0.4.dist-info → phasorpy-0.6.dist-info/licenses}/LICENSE.txt +0 -0
- {phasorpy-0.4.dist-info → phasorpy-0.6.dist-info}/top_level.txt +0 -0
phasorpy/_utils.py
CHANGED
@@ -2,26 +2,41 @@
|
|
2
2
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
|
-
__all__
|
5
|
+
__all__ = [
|
6
6
|
'chunk_iter',
|
7
7
|
'dilate_coordinates',
|
8
|
+
'init_module',
|
8
9
|
'kwargs_notnone',
|
9
10
|
'parse_harmonic',
|
10
11
|
'parse_kwargs',
|
11
12
|
'parse_signal_axis',
|
13
|
+
'parse_skip_axis',
|
12
14
|
'phasor_from_polar_scalar',
|
13
15
|
'phasor_to_polar_scalar',
|
14
16
|
'scale_matrix',
|
15
17
|
'sort_coordinates',
|
18
|
+
'squeeze_dims',
|
16
19
|
'update_kwargs',
|
20
|
+
'xarray_metadata',
|
17
21
|
]
|
18
22
|
|
19
23
|
import math
|
20
24
|
import numbers
|
25
|
+
import os
|
26
|
+
import sys
|
27
|
+
from collections.abc import Sequence
|
21
28
|
from typing import TYPE_CHECKING
|
22
29
|
|
23
30
|
if TYPE_CHECKING:
|
24
|
-
from ._typing import
|
31
|
+
from ._typing import (
|
32
|
+
Any,
|
33
|
+
ArrayLike,
|
34
|
+
Literal,
|
35
|
+
NDArray,
|
36
|
+
Iterator,
|
37
|
+
Container,
|
38
|
+
PathLike,
|
39
|
+
)
|
25
40
|
|
26
41
|
import numpy
|
27
42
|
|
@@ -268,7 +283,7 @@ def parse_signal_axis(
|
|
268
283
|
-------
|
269
284
|
axis : int
|
270
285
|
Axis over which phasor coordinates are computed.
|
271
|
-
axis_label: str
|
286
|
+
axis_label : str
|
272
287
|
Axis label from `signal.dims` if any.
|
273
288
|
|
274
289
|
Raises
|
@@ -312,6 +327,65 @@ def parse_signal_axis(
|
|
312
327
|
raise ValueError(f'{axis=} not valid for {type(signal)=}')
|
313
328
|
|
314
329
|
|
330
|
+
def parse_skip_axis(
|
331
|
+
skip_axis: int | Sequence[int] | None,
|
332
|
+
/,
|
333
|
+
ndim: int,
|
334
|
+
prepend_axis: bool = False,
|
335
|
+
) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
336
|
+
"""Return axes to skip and not to skip.
|
337
|
+
|
338
|
+
This helper function is used to validate and parse `skip_axis`
|
339
|
+
parameters.
|
340
|
+
|
341
|
+
Parameters
|
342
|
+
----------
|
343
|
+
skip_axis : int or sequence of int, optional
|
344
|
+
Axes to skip. If None, no axes are skipped.
|
345
|
+
ndim : int
|
346
|
+
Dimensionality of array in which to skip axes.
|
347
|
+
prepend_axis : bool, optional
|
348
|
+
Prepend one dimension and include in `skip_axis`.
|
349
|
+
|
350
|
+
Returns
|
351
|
+
-------
|
352
|
+
skip_axis : tuple of int
|
353
|
+
Ordered, positive values of `skip_axis`.
|
354
|
+
other_axis : tuple of int
|
355
|
+
Axes indices not included in `skip_axis`.
|
356
|
+
|
357
|
+
Raises
|
358
|
+
------
|
359
|
+
IndexError
|
360
|
+
If any `skip_axis` value is out of bounds of `ndim`.
|
361
|
+
|
362
|
+
Examples
|
363
|
+
--------
|
364
|
+
>>> parse_skip_axis((1, -2), 5)
|
365
|
+
((1, 3), (0, 2, 4))
|
366
|
+
|
367
|
+
>>> parse_skip_axis((1, -2), 5, True)
|
368
|
+
((0, 2, 4), (1, 3, 5))
|
369
|
+
|
370
|
+
"""
|
371
|
+
if ndim < 0:
|
372
|
+
raise ValueError(f'invalid {ndim=}')
|
373
|
+
if skip_axis is None:
|
374
|
+
if prepend_axis:
|
375
|
+
return (0,), tuple(range(1, ndim + 1))
|
376
|
+
return (), tuple(range(ndim))
|
377
|
+
if not isinstance(skip_axis, Sequence):
|
378
|
+
skip_axis = (skip_axis,)
|
379
|
+
if any(i >= ndim or i < -ndim for i in skip_axis):
|
380
|
+
raise IndexError(f'skip_axis={skip_axis} out of range for {ndim=}')
|
381
|
+
skip_axis = sorted(int(i % ndim) for i in skip_axis)
|
382
|
+
if prepend_axis:
|
383
|
+
skip_axis = [0] + [i + 1 for i in skip_axis]
|
384
|
+
ndim += 1
|
385
|
+
other_axis = tuple(i for i in range(ndim) if i not in skip_axis)
|
386
|
+
return tuple(skip_axis), other_axis
|
387
|
+
|
388
|
+
|
315
389
|
def parse_harmonic(
|
316
390
|
harmonic: int | Sequence[int] | Literal['all'] | str | None,
|
317
391
|
harmonic_max: int | None = None,
|
@@ -343,7 +417,7 @@ def parse_harmonic(
|
|
343
417
|
Raises
|
344
418
|
------
|
345
419
|
IndexError
|
346
|
-
Any element is out of range `[1
|
420
|
+
Any element is out of range `[1, harmonic_max]`.
|
347
421
|
ValueError
|
348
422
|
Elements are not unique.
|
349
423
|
Harmonic is empty.
|
@@ -364,7 +438,7 @@ def parse_harmonic(
|
|
364
438
|
if harmonic < 1 or (
|
365
439
|
harmonic_max is not None and harmonic > harmonic_max
|
366
440
|
):
|
367
|
-
raise IndexError(f'{harmonic=} out of range [1
|
441
|
+
raise IndexError(f'{harmonic=} out of range [1, {harmonic_max}]')
|
368
442
|
return [int(harmonic)], False
|
369
443
|
|
370
444
|
if isinstance(harmonic, str):
|
@@ -376,7 +450,7 @@ def parse_harmonic(
|
|
376
450
|
return list(range(1, harmonic_max + 1)), True
|
377
451
|
raise ValueError(f'{harmonic=!r} is not a valid harmonic')
|
378
452
|
|
379
|
-
h = numpy.atleast_1d(
|
453
|
+
h = numpy.atleast_1d(harmonic)
|
380
454
|
if h.size == 0:
|
381
455
|
raise ValueError(f'{harmonic=} is empty')
|
382
456
|
if h.dtype.kind not in 'iu' or h.ndim != 1:
|
@@ -387,7 +461,7 @@ def parse_harmonic(
|
|
387
461
|
raise IndexError(f'{harmonic=} element > {harmonic_max}]')
|
388
462
|
if numpy.unique(h).size != h.size:
|
389
463
|
raise ValueError(f'{harmonic=} elements must be unique')
|
390
|
-
return
|
464
|
+
return [int(i) for i in harmonic], True
|
391
465
|
|
392
466
|
|
393
467
|
def chunk_iter(
|
@@ -517,3 +591,123 @@ def chunk_iter(
|
|
517
591
|
for i in range(ndim)
|
518
592
|
),
|
519
593
|
)
|
594
|
+
|
595
|
+
|
596
|
+
def init_module(globs: dict[str, Any], /) -> None:
|
597
|
+
"""Add names in module to ``__all__`` and set ``__module__`` attributes.
|
598
|
+
|
599
|
+
Parameters
|
600
|
+
----------
|
601
|
+
globs : dict
|
602
|
+
Module namespace to modify.
|
603
|
+
|
604
|
+
Examples
|
605
|
+
--------
|
606
|
+
>>> init_module(globals())
|
607
|
+
|
608
|
+
"""
|
609
|
+
names = globs['__all__']
|
610
|
+
module_name = globs['__name__']
|
611
|
+
module = sys.modules[module_name]
|
612
|
+
for name in dir(module):
|
613
|
+
if name.startswith('_') or name in {
|
614
|
+
'annotations',
|
615
|
+
'init_module',
|
616
|
+
'utils', # TODO: where does this come from?
|
617
|
+
}:
|
618
|
+
continue
|
619
|
+
names.append(name)
|
620
|
+
obj = getattr(module, name)
|
621
|
+
if hasattr(obj, '__module__'):
|
622
|
+
obj.__module__ = module_name
|
623
|
+
globs['__all__'] = sorted(set(names))
|
624
|
+
|
625
|
+
|
626
|
+
def xarray_metadata(
|
627
|
+
dims: Sequence[str] | None,
|
628
|
+
shape: tuple[int, ...],
|
629
|
+
/,
|
630
|
+
name: str | PathLike[Any] | None = None,
|
631
|
+
attrs: dict[str, Any] | None = None,
|
632
|
+
**coords: Any,
|
633
|
+
) -> dict[str, Any]:
|
634
|
+
"""Return xarray-style dims, coords, and attrs in a dict.
|
635
|
+
|
636
|
+
>>> xarray_metadata('SYX', (3, 2, 1), S=['0', '1', '2'])
|
637
|
+
{'dims': ('S', 'Y', 'X'), 'coords': {'S': ['0', '1', '2']}, 'attrs': {}}
|
638
|
+
|
639
|
+
"""
|
640
|
+
assert dims is not None
|
641
|
+
dims = tuple(dims)
|
642
|
+
if len(dims) != len(shape):
|
643
|
+
raise ValueError(
|
644
|
+
f'dims do not match shape {len(dims)} != {len(shape)}'
|
645
|
+
)
|
646
|
+
coords = {dim: coords[dim] for dim in dims if dim in coords}
|
647
|
+
if attrs is None:
|
648
|
+
attrs = {}
|
649
|
+
metadata = {'dims': dims, 'coords': coords, 'attrs': attrs}
|
650
|
+
if name:
|
651
|
+
metadata['name'] = os.path.basename(name)
|
652
|
+
return metadata
|
653
|
+
|
654
|
+
|
655
|
+
def squeeze_dims(
|
656
|
+
shape: Sequence[int],
|
657
|
+
dims: Sequence[str],
|
658
|
+
/,
|
659
|
+
skip: Container[str] = 'XY',
|
660
|
+
) -> tuple[tuple[int, ...], tuple[str, ...], tuple[bool, ...]]:
|
661
|
+
"""Return shape and axes with length-1 dimensions removed.
|
662
|
+
|
663
|
+
Remove unused dimensions unless their axes are listed in the `skip`
|
664
|
+
parameter.
|
665
|
+
|
666
|
+
Adapted from the tifffile library.
|
667
|
+
|
668
|
+
Parameters
|
669
|
+
----------
|
670
|
+
shape : tuple of ints
|
671
|
+
Sequence of dimension sizes.
|
672
|
+
dims : sequence of str
|
673
|
+
Character codes for dimensions in `shape`.
|
674
|
+
skip : container of str, optional
|
675
|
+
Character codes for dimensions whose length-1 dimensions are
|
676
|
+
not removed. The default is 'XY'.
|
677
|
+
|
678
|
+
Returns
|
679
|
+
-------
|
680
|
+
shape : tuple of ints
|
681
|
+
Sequence of dimension sizes with length-1 dimensions removed.
|
682
|
+
dims : tuple of str
|
683
|
+
Character codes for dimensions in output `shape`.
|
684
|
+
squeezed : str
|
685
|
+
Dimensions were kept (True) or removed (False).
|
686
|
+
|
687
|
+
Examples
|
688
|
+
--------
|
689
|
+
>>> squeeze_dims((5, 1, 2, 1, 1), 'TZYXC')
|
690
|
+
((5, 2, 1), ('T', 'Y', 'X'), (True, False, True, True, False))
|
691
|
+
>>> squeeze_dims((1,), ('Q',))
|
692
|
+
((1,), ('Q',), (True,))
|
693
|
+
|
694
|
+
"""
|
695
|
+
if len(shape) != len(dims):
|
696
|
+
raise ValueError(f'{len(shape)=} != {len(dims)=}')
|
697
|
+
if not dims:
|
698
|
+
return tuple(shape), tuple(dims), ()
|
699
|
+
squeezed: list[bool] = []
|
700
|
+
shape_squeezed: list[int] = []
|
701
|
+
dims_squeezed: list[str] = []
|
702
|
+
for size, ax in zip(shape, dims):
|
703
|
+
if size > 1 or ax in skip:
|
704
|
+
squeezed.append(True)
|
705
|
+
shape_squeezed.append(size)
|
706
|
+
dims_squeezed.append(ax)
|
707
|
+
else:
|
708
|
+
squeezed.append(False)
|
709
|
+
if len(shape_squeezed) == 0:
|
710
|
+
squeezed[-1] = True
|
711
|
+
shape_squeezed.append(shape[-1])
|
712
|
+
dims_squeezed.append(dims[-1])
|
713
|
+
return tuple(shape_squeezed), tuple(dims_squeezed), tuple(squeezed)
|
phasorpy/cli.py
CHANGED
@@ -8,6 +8,8 @@ Invoke the command line application with::
|
|
8
8
|
|
9
9
|
from __future__ import annotations
|
10
10
|
|
11
|
+
__all__: list[str] = []
|
12
|
+
|
11
13
|
import os
|
12
14
|
from typing import TYPE_CHECKING
|
13
15
|
|
@@ -16,11 +18,11 @@ if TYPE_CHECKING:
|
|
16
18
|
|
17
19
|
import click
|
18
20
|
|
19
|
-
from . import
|
21
|
+
from . import __version__
|
20
22
|
|
21
23
|
|
22
24
|
@click.group(help='PhasorPy package command line interface.')
|
23
|
-
@click.version_option(version=
|
25
|
+
@click.version_option(version=__version__)
|
24
26
|
def main() -> int:
|
25
27
|
"""PhasorPy command line interface."""
|
26
28
|
return 0
|
@@ -36,7 +38,9 @@ def main() -> int:
|
|
36
38
|
)
|
37
39
|
def versions(verbose: bool) -> None:
|
38
40
|
"""Versions command group."""
|
39
|
-
|
41
|
+
from .utils import versions
|
42
|
+
|
43
|
+
click.echo(versions(verbose=verbose))
|
40
44
|
|
41
45
|
|
42
46
|
@main.command(help='Fetch sample files from remote repositories.')
|
@@ -81,6 +85,57 @@ def fret(hide: bool) -> None:
|
|
81
85
|
plot.show()
|
82
86
|
|
83
87
|
|
88
|
+
@main.command(help='Start interactive lifetime plots.')
|
89
|
+
@click.option(
|
90
|
+
'-f',
|
91
|
+
'--frequency',
|
92
|
+
type=float,
|
93
|
+
required=False,
|
94
|
+
help='Laser/modulation frequency in MHz.',
|
95
|
+
)
|
96
|
+
@click.option(
|
97
|
+
'-l',
|
98
|
+
'--lifetime',
|
99
|
+
default=(4.0, 1.0),
|
100
|
+
type=float,
|
101
|
+
multiple=True,
|
102
|
+
required=False,
|
103
|
+
help='Lifetime in ns.',
|
104
|
+
)
|
105
|
+
@click.option(
|
106
|
+
'-a',
|
107
|
+
'--fraction',
|
108
|
+
type=float,
|
109
|
+
multiple=True,
|
110
|
+
required=False,
|
111
|
+
help='Fractional intensity of lifetime.',
|
112
|
+
)
|
113
|
+
@click.option(
|
114
|
+
'--hide',
|
115
|
+
default=False,
|
116
|
+
is_flag=True,
|
117
|
+
type=click.BOOL,
|
118
|
+
help='Do not show interactive plot.',
|
119
|
+
)
|
120
|
+
def lifetime(
|
121
|
+
frequency: float | None,
|
122
|
+
lifetime: tuple[float, ...],
|
123
|
+
fraction: tuple[float, ...],
|
124
|
+
hide: bool,
|
125
|
+
) -> None:
|
126
|
+
"""Lifetime command group."""
|
127
|
+
from .plot import LifetimePlots
|
128
|
+
|
129
|
+
plot = LifetimePlots(
|
130
|
+
frequency,
|
131
|
+
lifetime,
|
132
|
+
fraction if len(fraction) > 0 else None,
|
133
|
+
interactive=True,
|
134
|
+
)
|
135
|
+
if not hide:
|
136
|
+
plot.show()
|
137
|
+
|
138
|
+
|
84
139
|
if __name__ == '__main__':
|
85
140
|
import sys
|
86
141
|
|
phasorpy/cluster.py
ADDED
@@ -0,0 +1,206 @@
|
|
1
|
+
"""Cluster phasor coordinates.
|
2
|
+
|
3
|
+
The `phasorpy.cluster` module provides functions to:
|
4
|
+
|
5
|
+
- fit elliptic clusters to phasor coordinates using
|
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
|
+
from sklearn.mixture import GaussianMixture
|
25
|
+
|
26
|
+
|
27
|
+
def phasor_cluster_gmm(
|
28
|
+
real: ArrayLike,
|
29
|
+
imag: ArrayLike,
|
30
|
+
/,
|
31
|
+
*,
|
32
|
+
sigma: float = 2.0,
|
33
|
+
clusters: int = 1,
|
34
|
+
sort: Literal['polar', 'phasor', 'area'] | None = None,
|
35
|
+
**kwargs: Any,
|
36
|
+
) -> tuple[
|
37
|
+
tuple[float, ...],
|
38
|
+
tuple[float, ...],
|
39
|
+
tuple[float, ...],
|
40
|
+
tuple[float, ...],
|
41
|
+
tuple[float, ...],
|
42
|
+
]:
|
43
|
+
"""Return elliptic clusters in phasor coordinates using GMM.
|
44
|
+
|
45
|
+
Fit a Gaussian Mixture Model (GMM) to the provided phasor coordinates and
|
46
|
+
extract the parameters of ellipses that represent each cluster according
|
47
|
+
to [1]_.
|
48
|
+
|
49
|
+
Parameters
|
50
|
+
----------
|
51
|
+
real : array_like
|
52
|
+
Real component of phasor coordinates.
|
53
|
+
imag : array_like
|
54
|
+
Imaginary component of phasor coordinates.
|
55
|
+
sigma: float, default = 2.0
|
56
|
+
Scaling factor for radii of major and minor axes.
|
57
|
+
Defaults to 2, which corresponds to the scaling of eigenvalues for a
|
58
|
+
95% confidence ellipse.
|
59
|
+
clusters : int, optional
|
60
|
+
Number of Gaussian distributions to fit to phasor coordinates.
|
61
|
+
Defaults to 1.
|
62
|
+
sort: {'polar', 'phasor', 'area'}, optional
|
63
|
+
Sorting method for output clusters. Defaults to 'polar'.
|
64
|
+
|
65
|
+
- 'polar': Sort by polar coordinates (phase, then modulation).
|
66
|
+
- 'phasor': Sort by phasor coordinates (real, then imaginary).
|
67
|
+
- 'area': Sort by inverse area of ellipse (-major * minor).
|
68
|
+
|
69
|
+
**kwargs
|
70
|
+
Additional keyword arguments passed to
|
71
|
+
:py:class:`sklearn.mixture.GaussianMixture`.
|
72
|
+
|
73
|
+
Common options include:
|
74
|
+
|
75
|
+
- covariance_type : {'full', 'tied', 'diag', 'spherical'}
|
76
|
+
- max_iter : int, maximum number of EM iterations
|
77
|
+
- random_state : int, for reproducible results
|
78
|
+
|
79
|
+
Returns
|
80
|
+
-------
|
81
|
+
center_real : tuple of float
|
82
|
+
Real component of ellipse centers.
|
83
|
+
center_imag : tuple of float
|
84
|
+
Imaginary component of ellipse centers.
|
85
|
+
radius_major : tuple of float
|
86
|
+
Major radii of ellipses.
|
87
|
+
radius_minor : tuple of float
|
88
|
+
Minor radii of ellipses.
|
89
|
+
angle : tuple of float
|
90
|
+
Rotation angles of major axes in radians, within range [0, pi].
|
91
|
+
|
92
|
+
Raises
|
93
|
+
------
|
94
|
+
ValueError
|
95
|
+
If the array shapes of `real` and `imag` do not match.
|
96
|
+
If `clusters` is not a positive integer.
|
97
|
+
|
98
|
+
|
99
|
+
References
|
100
|
+
----------
|
101
|
+
.. [1] Vallmitjana A, Torrado B, and Gratton E.
|
102
|
+
`Phasor-based image segmentation: machine learning clustering techniques
|
103
|
+
<https://doi.org/10.1364/BOE.422766>`_.
|
104
|
+
*Biomed Opt Express*, 12(6): 3410-3422 (2021).
|
105
|
+
|
106
|
+
Examples
|
107
|
+
--------
|
108
|
+
Recover the clusters from a synthetic distribution of phasor coordinates
|
109
|
+
with two clusters:
|
110
|
+
|
111
|
+
>>> real1, imag1 = numpy.random.multivariate_normal(
|
112
|
+
... [0.2, 0.3], [[3e-3, 1e-3], [1e-3, 2e-3]], 100
|
113
|
+
... ).T
|
114
|
+
>>> real2, imag2 = numpy.random.multivariate_normal(
|
115
|
+
... [0.4, 0.5], [[2e-3, -1e-3], [-1e-3, 3e-3]], 100
|
116
|
+
... ).T
|
117
|
+
>>> real = numpy.concatenate([real1, real2])
|
118
|
+
>>> imag = numpy.concatenate([imag1, imag2])
|
119
|
+
>>> center_real, center_imag, radius_major, radius_minor, angle = (
|
120
|
+
... phasor_cluster_gmm(real, imag, clusters=2)
|
121
|
+
... )
|
122
|
+
>>> centers_real # doctest: +SKIP
|
123
|
+
(0.2, 0.4)
|
124
|
+
|
125
|
+
"""
|
126
|
+
coords = numpy.stack((real, imag), axis=-1).reshape(-1, 2)
|
127
|
+
|
128
|
+
valid_data = ~numpy.isnan(coords).any(axis=1)
|
129
|
+
coords = coords[valid_data]
|
130
|
+
|
131
|
+
kwargs.pop('n_components', None)
|
132
|
+
|
133
|
+
gmm = GaussianMixture(n_components=clusters, **kwargs)
|
134
|
+
gmm.fit(coords)
|
135
|
+
|
136
|
+
center_real = []
|
137
|
+
center_imag = []
|
138
|
+
radius_major = []
|
139
|
+
radius_minor = []
|
140
|
+
angle = []
|
141
|
+
|
142
|
+
for i in range(clusters):
|
143
|
+
center_real.append(float(gmm.means_[i, 0]))
|
144
|
+
center_imag.append(float(gmm.means_[i, 1]))
|
145
|
+
|
146
|
+
if gmm.covariance_type == 'full':
|
147
|
+
cov = gmm.covariances_[i]
|
148
|
+
elif gmm.covariance_type == 'tied':
|
149
|
+
cov = gmm.covariances_
|
150
|
+
elif gmm.covariance_type == 'diag':
|
151
|
+
cov = numpy.diag(gmm.covariances_[i])
|
152
|
+
else: # 'spherical'
|
153
|
+
cov = numpy.eye(2) * gmm.covariances_[i]
|
154
|
+
|
155
|
+
eigenvalues, eigenvectors = numpy.linalg.eigh(cov[:2, :2])
|
156
|
+
|
157
|
+
idx = eigenvalues.argsort()[::-1]
|
158
|
+
eigenvalues = eigenvalues[idx]
|
159
|
+
eigenvectors = eigenvectors[:, idx]
|
160
|
+
|
161
|
+
major_vector = eigenvectors[:, 0]
|
162
|
+
current_angle = math.atan2(major_vector[1], major_vector[0])
|
163
|
+
|
164
|
+
if current_angle < 0:
|
165
|
+
current_angle += math.pi
|
166
|
+
|
167
|
+
angle.append(float(current_angle))
|
168
|
+
|
169
|
+
radius_major.append(sigma * math.sqrt(2 * eigenvalues[0]))
|
170
|
+
radius_minor.append(sigma * math.sqrt(2 * eigenvalues[1]))
|
171
|
+
|
172
|
+
if clusters == 1:
|
173
|
+
argsort = [0]
|
174
|
+
else:
|
175
|
+
if sort is None or sort == 'polar':
|
176
|
+
|
177
|
+
def sort_key(i: int) -> Any:
|
178
|
+
return (
|
179
|
+
math.atan2(center_imag[i], center_real[i]),
|
180
|
+
math.hypot(center_real[i], center_imag[i]),
|
181
|
+
)
|
182
|
+
|
183
|
+
elif sort == 'phasor':
|
184
|
+
|
185
|
+
def sort_key(i: int) -> Any:
|
186
|
+
return center_imag[i], center_real[i]
|
187
|
+
|
188
|
+
elif sort == 'area':
|
189
|
+
|
190
|
+
def sort_key(i: int) -> Any:
|
191
|
+
return -radius_major[i] * radius_minor[i]
|
192
|
+
|
193
|
+
else:
|
194
|
+
raise ValueError(
|
195
|
+
f"invalid {sort=!r} != 'phasor', 'polar', or 'area'"
|
196
|
+
)
|
197
|
+
|
198
|
+
argsort = sorted(range(len(center_real)), key=sort_key)
|
199
|
+
|
200
|
+
return (
|
201
|
+
tuple(center_real[i] for i in argsort),
|
202
|
+
tuple(center_imag[i] for i in argsort),
|
203
|
+
tuple(radius_major[i] for i in argsort),
|
204
|
+
tuple(radius_minor[i] for i in argsort),
|
205
|
+
tuple(angle[i] for i in argsort),
|
206
|
+
)
|
phasorpy/color.py
CHANGED
@@ -19,13 +19,13 @@ def wavelength2rgb(
|
|
19
19
|
) -> tuple[float, float, float] | NDArray[Any]:
|
20
20
|
"""Return approximate sRGB color components of visible wavelength(s).
|
21
21
|
|
22
|
-
|
23
|
-
the :py:attr:`SRGB_SPECTRUM` palette.
|
22
|
+
Wavelengths are clipped to range [360, 750] nm, rounded, and used to
|
23
|
+
index the :py:attr:`SRGB_SPECTRUM` palette.
|
24
24
|
|
25
25
|
Parameters
|
26
26
|
----------
|
27
27
|
wavelength : array_like
|
28
|
-
Scalar or array of
|
28
|
+
Scalar or array of wavelengths in nm.
|
29
29
|
dtype : data-type, optional
|
30
30
|
Data-type of return value. The default is ``float32``.
|
31
31
|
|
@@ -33,8 +33,8 @@ def wavelength2rgb(
|
|
33
33
|
-------
|
34
34
|
ndarray or tuple
|
35
35
|
Approximate sRGB color components of visible wavelength.
|
36
|
-
Floating-point
|
37
|
-
Integer
|
36
|
+
Floating-point values are in range [0.0, 1.0].
|
37
|
+
Integer values are scaled to the dtype's maximum value.
|
38
38
|
|
39
39
|
Examples
|
40
40
|
--------
|
@@ -68,7 +68,7 @@ def float2int(
|
|
68
68
|
/,
|
69
69
|
dtype: DTypeLike = numpy.uint8,
|
70
70
|
) -> NDArray[Any]:
|
71
|
-
"""Return normalized color components as
|
71
|
+
"""Return normalized color components as integers.
|
72
72
|
|
73
73
|
Parameters
|
74
74
|
----------
|
@@ -77,6 +77,11 @@ def float2int(
|
|
77
77
|
dtype : data-type, optional
|
78
78
|
Data type of return value. The default is ``uint8``.
|
79
79
|
|
80
|
+
Returns
|
81
|
+
-------
|
82
|
+
ndarray
|
83
|
+
Color components as integers scaled to dtype's range.
|
84
|
+
|
80
85
|
Examples
|
81
86
|
--------
|
82
87
|
>>> float2int([0.0, 0.5, 1.0])
|
@@ -162,11 +167,11 @@ CATEGORICAL: NDArray[numpy.float32] = numpy.array([
|
|
162
167
|
[0.523809, 0.888889, 0.460317],
|
163
168
|
[0.285714, 0.0, 0.238095],
|
164
169
|
], dtype=numpy.float32)
|
165
|
-
"""Categorical sRGB color palette inspired by C Glasbey.
|
170
|
+
"""Categorical sRGB color palette inspired by C. Glasbey.
|
166
171
|
|
167
|
-
|
172
|
+
Contains 64 maximally distinct colours for visualization.
|
168
173
|
|
169
|
-
Generated
|
174
|
+
Generated using the `glasbey <https://glasbey.readthedocs.io>`_ package::
|
170
175
|
|
171
176
|
import glasbey; numpy.array(glasbey.create_palette(64, as_hex=False))
|
172
177
|
|
@@ -568,11 +573,11 @@ SRGB_SPECTRUM: NDArray[numpy.float32] = numpy.array([
|
|
568
573
|
[0.005006, 0.0, 0.0],
|
569
574
|
[0.004664, 0.0, 0.0],
|
570
575
|
], dtype=numpy.float32)
|
571
|
-
"""sRGB color components for visible light
|
576
|
+
"""sRGB color components for wavelengths of visible light (360-750 nm).
|
572
577
|
|
573
578
|
Based on the CIE 1931 2° Standard Observer.
|
574
579
|
|
575
|
-
Generated
|
580
|
+
Generated using the `colour <https://colour.readthedocs.io>`_ package::
|
576
581
|
|
577
582
|
import colour; colour.plotting.plot_visible_spectrum()
|
578
583
|
|