phasorpy 0.5__cp311-cp311-win_arm64.whl → 0.7__cp311-cp311-win_arm64.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/_utils.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
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',
@@ -13,18 +14,29 @@ __all__ = [
13
14
  'phasor_from_polar_scalar',
14
15
  'phasor_to_polar_scalar',
15
16
  'scale_matrix',
16
- 'set_module',
17
17
  'sort_coordinates',
18
+ 'squeeze_dims',
18
19
  'update_kwargs',
20
+ 'xarray_metadata',
19
21
  ]
20
22
 
21
23
  import math
22
24
  import numbers
25
+ import os
26
+ import sys
23
27
  from collections.abc import Sequence
24
28
  from typing import TYPE_CHECKING
25
29
 
26
30
  if TYPE_CHECKING:
27
- from ._typing import Any, ArrayLike, Literal, NDArray, Iterator
31
+ from ._typing import (
32
+ Any,
33
+ ArrayLike,
34
+ Literal,
35
+ NDArray,
36
+ Iterator,
37
+ Container,
38
+ PathLike,
39
+ )
28
40
 
29
41
  import numpy
30
42
 
@@ -40,6 +52,23 @@ def parse_kwargs(
40
52
 
41
53
  If `_del` is true (default), existing keys are deleted from `kwargs`.
42
54
 
55
+ Parameters
56
+ ----------
57
+ kwargs : dict
58
+ Source dictionary to extract keys from.
59
+ *keys : str
60
+ Keys to extract from kwargs if present.
61
+ _del : bool, default: True
62
+ If True, remove extracted keys from kwargs.
63
+ **keyvalues : Any
64
+ Key-value pairs. If key exists in kwargs, use kwargs value,
65
+ otherwise use provided default value.
66
+
67
+ Returns
68
+ -------
69
+ dict
70
+ Dictionary containing extracted keys and values.
71
+
43
72
  >>> kwargs = {'one': 1, 'two': 2, 'four': 4}
44
73
  >>> kwargs2 = parse_kwargs(kwargs, 'two', 'three', four=None, five=5)
45
74
  >>> kwargs == {'one': 1}
@@ -93,19 +122,19 @@ def scale_matrix(factor: float, origin: Sequence[float]) -> NDArray[Any]:
93
122
 
94
123
  Parameters
95
124
  ----------
96
- factor: float
125
+ factor : float
97
126
  Scale factor.
98
- origin: (float, float)
127
+ origin : (float, float)
99
128
  Coordinates of point around which to scale.
100
129
 
101
130
  Returns
102
131
  -------
103
- matrix: ndarray
132
+ matrix : ndarray
104
133
  A 3x3 homogeneous transformation matrix.
105
134
 
106
135
  Examples
107
136
  --------
108
- >>> scale_matrix(1.1, (0.0, 0.5))
137
+ >>> scale_matrix(1.1, [0.0, 0.5])
109
138
  array([[1.1, 0, -0],
110
139
  [0, 1.1, -0.05],
111
140
  [0, 0, 1]])
@@ -121,7 +150,7 @@ def sort_coordinates(
121
150
  real: ArrayLike,
122
151
  imag: ArrayLike,
123
152
  /,
124
- origin: tuple[float, float] | None = None,
153
+ origin: ArrayLike | None = None,
125
154
  ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
126
155
  """Return cartesian coordinates sorted counterclockwise around origin.
127
156
 
@@ -129,15 +158,19 @@ def sort_coordinates(
129
158
  ----------
130
159
  real, imag : array_like
131
160
  Coordinates to be sorted.
132
- origin : (float, float)
161
+ origin : array_like, optional
133
162
  Coordinates around which to sort by angle.
163
+ By default, sort around the mean of `real` and `imag`.
134
164
 
135
165
  Returns
136
166
  -------
137
- real, imag : ndarray
138
- Coordinates sorted by angle.
167
+ real : ndarray
168
+ Sorted real coordinates.
169
+ imag : ndarray
170
+ Sorted imaginary coordinates.
139
171
  indices : ndarray
140
172
  Indices used to reorder coordinates.
173
+ Use ``indices.argsort()`` to get original order.
141
174
 
142
175
  Examples
143
176
  --------
@@ -148,11 +181,15 @@ def sort_coordinates(
148
181
  x, y = numpy.atleast_1d(real, imag)
149
182
  if x.ndim != 1 or x.shape != y.shape:
150
183
  raise ValueError(f'invalid {x.shape=} or {y.shape=}')
151
- if x.size < 4:
184
+ if x.size < 3:
152
185
  return x, y, numpy.arange(x.size)
153
186
  if origin is None:
154
- origin = x.mean(), y.mean()
155
- indices = numpy.argsort(numpy.arctan2(y - origin[1], x - origin[0]))
187
+ ox, oy = x.mean(), y.mean()
188
+ else:
189
+ origin = numpy.asarray(origin, dtype=numpy.float64)
190
+ ox = origin[0]
191
+ oy = origin[1]
192
+ indices = numpy.argsort(numpy.arctan2(y - oy, x - ox))
156
193
  return x[indices], y[indices], indices
157
194
 
158
195
 
@@ -173,8 +210,10 @@ def dilate_coordinates(
173
210
 
174
211
  Returns
175
212
  -------
176
- real, imag : ndarray
177
- Coordinates dilated by offset.
213
+ real : ndarray
214
+ Dilated real coordinates.
215
+ imag : ndarray
216
+ Dilated imaginary coordinates.
178
217
 
179
218
  Examples
180
219
  --------
@@ -213,8 +252,28 @@ def phasor_to_polar_scalar(
213
252
  ) -> tuple[float, float]:
214
253
  """Return polar from scalar phasor coordinates.
215
254
 
216
- >>> phasor_to_polar_scalar(1.0, 0.0, degree=True, percent=True)
217
- (0.0, 100.0)
255
+ Parameters
256
+ ----------
257
+ real : float
258
+ Real component of phasor coordinate.
259
+ imag : float
260
+ Imaginary component of phasor coordinate.
261
+ degree : bool, optional
262
+ If true, return phase in degrees instead of radians.
263
+ percent : bool, optional
264
+ If true, return modulation as percentage instead of fraction.
265
+
266
+ Returns
267
+ -------
268
+ phase : float
269
+ Phase angle in radians (or degrees if degree=True).
270
+ modulation : float
271
+ Modulation depth as fraction (or percentage if percent=True).
272
+
273
+ Examples
274
+ --------
275
+ >>> phasor_to_polar_scalar(0.0, 1.0, degree=True, percent=True)
276
+ (90.0, 100.0)
218
277
 
219
278
  """
220
279
  phi = math.atan2(imag, real)
@@ -236,6 +295,26 @@ def phasor_from_polar_scalar(
236
295
  ) -> tuple[float, float]:
237
296
  """Return phasor from scalar polar coordinates.
238
297
 
298
+ Parameters
299
+ ----------
300
+ phase : float
301
+ Phase angle in radians (or degrees if degree=True).
302
+ modulation : float
303
+ Modulation depth as fraction (or percentage if percent=True).
304
+ degree : bool, optional
305
+ If true, phase is in degrees instead of radians.
306
+ percent : bool, optional
307
+ If true, modulation is as percentage instead of fraction.
308
+
309
+ Returns
310
+ -------
311
+ real : float
312
+ Real component of phasor coordinate.
313
+ imag : float
314
+ Imaginary component of phasor coordinate.
315
+
316
+ Examples
317
+ --------
239
318
  >>> phasor_from_polar_scalar(0.0, 100.0, degree=True, percent=True)
240
319
  (1.0, 0.0)
241
320
 
@@ -261,23 +340,27 @@ def parse_signal_axis(
261
340
  Parameters
262
341
  ----------
263
342
  signal : array_like
264
- Image stack.
265
- axis : int or str, optional
266
- Axis over which phasor coordinates are computed.
267
- By default, the 'H' or 'C' axes if `signal` contains such
268
- dimension names, else the last axis (-1).
343
+ Signal array.
344
+ Axis names are used if it has a `dims` attribute.
345
+ axis : int, str, or None, default: None
346
+ Axis over which to compute phasor coordinates.
347
+ If None, automatically selects 'H' or 'C' axis if available,
348
+ otherwise uses the last axis (-1).
349
+ If int, specifies axis index.
350
+ If str, specifies axis name (requires `signal.dims`).
269
351
 
270
352
  Returns
271
353
  -------
272
354
  axis : int
273
- Axis over which phasor coordinates are computed.
355
+ Index of axis over which phasor coordinates are computed.
274
356
  axis_label : str
275
- Axis label from `signal.dims` if any.
357
+ Label of axis from `signal.dims` if available, empty string otherwise.
276
358
 
277
359
  Raises
278
360
  ------
279
361
  ValueError
280
- Axis not found in signal.dims or invalid for signal type.
362
+ If axis string is not found in signal.dims.
363
+ If axis string is provided but signal has no dims attribute.
281
364
 
282
365
  Examples
283
366
  --------
@@ -344,15 +427,17 @@ def parse_skip_axis(
344
427
 
345
428
  Raises
346
429
  ------
430
+ ValueError
431
+ If ndim is negative.
347
432
  IndexError
348
433
  If any `skip_axis` value is out of bounds of `ndim`.
349
434
 
350
435
  Examples
351
436
  --------
352
- >>> parse_skip_axis((1, -2), 5)
437
+ >>> parse_skip_axis([1, -2], 5)
353
438
  ((1, 3), (0, 2, 4))
354
439
 
355
- >>> parse_skip_axis((1, -2), 5, True)
440
+ >>> parse_skip_axis([1, -2], 5, True)
356
441
  ((0, 2, 4), (1, 3, 5))
357
442
 
358
443
  """
@@ -389,7 +474,7 @@ def parse_harmonic(
389
474
  harmonic : int, sequence of int, 'all', or None
390
475
  Harmonic parameter to parse.
391
476
  harmonic_max : int, optional
392
- Maximum value allowed in `hamonic`. Must be one or greater.
477
+ Maximum value allowed in `harmonic`. Must be one or greater.
393
478
  To verify against known number of signal samples,
394
479
  pass ``samples // 2``.
395
480
  If `harmonic='all'`, a range of harmonics from one to `harmonic_max`
@@ -475,11 +560,11 @@ def chunk_iter(
475
560
  pattern : str, optional
476
561
  String to format chunk indices.
477
562
  If None, use ``_[{dims[index]}{chunk_index[index]}]`` for each axis.
478
- squeeze : bool
563
+ squeeze : bool, optional
479
564
  If true, do not include length-1 chunked dimensions in label
480
565
  unless dimensions are part of `chunk_shape`.
481
566
  Applies only if `pattern` is None.
482
- use_index : bool
567
+ use_index : bool, optional
483
568
  If true, use indices of chunks in `shape` instead of chunk indices to
484
569
  format pattern.
485
570
 
@@ -581,8 +666,8 @@ def chunk_iter(
581
666
  )
582
667
 
583
668
 
584
- def set_module(globs: dict[str, Any], /) -> None:
585
- """Set ``__module__`` attribute for objects in ``__all__``.
669
+ def init_module(globs: dict[str, Any], /) -> None:
670
+ """Add names in module to ``__all__`` and set ``__module__`` attributes.
586
671
 
587
672
  Parameters
588
673
  ----------
@@ -591,11 +676,111 @@ def set_module(globs: dict[str, Any], /) -> None:
591
676
 
592
677
  Examples
593
678
  --------
594
- >>> set_module(globals())
679
+ >>> init_module(globals())
595
680
 
596
681
  """
597
- name = globs['__name__']
598
- for item in globs['__all__']:
599
- obj = globs[item]
682
+ names = globs['__all__']
683
+ module_name = globs['__name__']
684
+ module = sys.modules[module_name]
685
+ for name in dir(module):
686
+ if name.startswith('_') or name in {
687
+ 'annotations',
688
+ 'init_module',
689
+ 'utils', # TODO: where does this come from?
690
+ }:
691
+ continue
692
+ names.append(name)
693
+ obj = getattr(module, name)
600
694
  if hasattr(obj, '__module__'):
601
- obj.__module__ = name
695
+ obj.__module__ = module_name
696
+ globs['__all__'] = sorted(set(names))
697
+
698
+
699
+ def xarray_metadata(
700
+ dims: Sequence[str] | None,
701
+ shape: tuple[int, ...],
702
+ /,
703
+ name: str | PathLike[Any] | None = None,
704
+ attrs: dict[str, Any] | None = None,
705
+ **coords: Any,
706
+ ) -> dict[str, Any]:
707
+ """Return xarray-style dims, coords, and attrs in a dict.
708
+
709
+ >>> xarray_metadata('SYX', (3, 2, 1), S=['0', '1', '2'])
710
+ {'dims': ('S', 'Y', 'X'), 'coords': {'S': ['0', '1', '2']}, 'attrs': {}}
711
+
712
+ """
713
+ assert dims is not None
714
+ dims = tuple(dims)
715
+ if len(dims) != len(shape):
716
+ raise ValueError(
717
+ f'dims do not match shape {len(dims)} != {len(shape)}'
718
+ )
719
+ coords = {dim: coords[dim] for dim in dims if dim in coords}
720
+ if attrs is None:
721
+ attrs = {}
722
+ metadata = {'dims': dims, 'coords': coords, 'attrs': attrs}
723
+ if name:
724
+ metadata['name'] = os.path.basename(name)
725
+ return metadata
726
+
727
+
728
+ def squeeze_dims(
729
+ shape: Sequence[int],
730
+ dims: Sequence[str],
731
+ /,
732
+ skip: Container[str] = 'XY',
733
+ ) -> tuple[tuple[int, ...], tuple[str, ...], tuple[bool, ...]]:
734
+ """Return shape and axes with length-1 dimensions removed.
735
+
736
+ Remove unused dimensions unless their axes are listed in the `skip`
737
+ parameter.
738
+
739
+ Adapted from the tifffile library.
740
+
741
+ Parameters
742
+ ----------
743
+ shape : tuple of ints
744
+ Sequence of dimension sizes.
745
+ dims : sequence of str
746
+ Character codes for dimensions in `shape`.
747
+ skip : container of str, optional
748
+ Character codes for dimensions whose length-1 dimensions are
749
+ not removed. The default is 'XY'.
750
+
751
+ Returns
752
+ -------
753
+ shape : tuple of ints
754
+ Sequence of dimension sizes with length-1 dimensions removed.
755
+ dims : tuple of str
756
+ Character codes for dimensions in output `shape`.
757
+ squeezed : str
758
+ Dimensions were kept (True) or removed (False).
759
+
760
+ Examples
761
+ --------
762
+ >>> squeeze_dims((5, 1, 2, 1, 1), 'TZYXC')
763
+ ((5, 2, 1), ('T', 'Y', 'X'), (True, False, True, True, False))
764
+ >>> squeeze_dims((1,), ('Q',))
765
+ ((1,), ('Q',), (True,))
766
+
767
+ """
768
+ if len(shape) != len(dims):
769
+ raise ValueError(f'{len(shape)=} != {len(dims)=}')
770
+ if not dims:
771
+ return tuple(shape), tuple(dims), ()
772
+ squeezed: list[bool] = []
773
+ shape_squeezed: list[int] = []
774
+ dims_squeezed: list[str] = []
775
+ for size, ax in zip(shape, dims):
776
+ if size > 1 or ax in skip:
777
+ squeezed.append(True)
778
+ shape_squeezed.append(size)
779
+ dims_squeezed.append(ax)
780
+ else:
781
+ squeezed.append(False)
782
+ if len(shape_squeezed) == 0:
783
+ squeezed[-1] = True
784
+ shape_squeezed.append(shape[-1])
785
+ dims_squeezed.append(dims[-1])
786
+ return tuple(shape_squeezed), tuple(dims_squeezed), tuple(squeezed)
phasorpy/cli.py CHANGED
@@ -18,11 +18,11 @@ if TYPE_CHECKING:
18
18
 
19
19
  import click
20
20
 
21
- from . import version
21
+ from . import __version__
22
22
 
23
23
 
24
24
  @click.group(help='PhasorPy package command line interface.')
25
- @click.version_option(version=version.__version__)
25
+ @click.version_option(version=__version__)
26
26
  def main() -> int:
27
27
  """PhasorPy command line interface."""
28
28
  return 0
@@ -38,7 +38,9 @@ def main() -> int:
38
38
  )
39
39
  def versions(verbose: bool) -> None:
40
40
  """Versions command group."""
41
- click.echo(version.versions(verbose=verbose))
41
+ from .utils import versions
42
+
43
+ click.echo(versions(verbose=verbose))
42
44
 
43
45
 
44
46
  @main.command(help='Fetch sample files from remote repositories.')
@@ -83,6 +85,75 @@ def fret(hide: bool) -> None:
83
85
  plot.show()
84
86
 
85
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
+
86
157
  if __name__ == '__main__':
87
158
  import sys
88
159
 
phasorpy/cluster.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The `phasorpy.cluster` module provides functions to:
4
4
 
5
- - fit elliptic clusters to phasor coordinates using
5
+ - fit elliptic clusters to phasor coordinates using a
6
6
  Gaussian Mixture Model (GMM):
7
7
 
8
8
  - :py:func:`phasor_cluster_gmm`
@@ -16,12 +16,11 @@ __all__ = ['phasor_cluster_gmm']
16
16
  from typing import TYPE_CHECKING
17
17
 
18
18
  if TYPE_CHECKING:
19
- from ._typing import Any, ArrayLike
19
+ from ._typing import Any, ArrayLike, Literal
20
20
 
21
21
  import math
22
22
 
23
23
  import numpy
24
- from sklearn.mixture import GaussianMixture
25
24
 
26
25
 
27
26
  def phasor_cluster_gmm(
@@ -31,6 +30,7 @@ def phasor_cluster_gmm(
31
30
  *,
32
31
  sigma: float = 2.0,
33
32
  clusters: int = 1,
33
+ sort: Literal['polar', 'phasor', 'area'] | None = None,
34
34
  **kwargs: Any,
35
35
  ) -> tuple[
36
36
  tuple[float, ...],
@@ -51,13 +51,20 @@ def phasor_cluster_gmm(
51
51
  Real component of phasor coordinates.
52
52
  imag : array_like
53
53
  Imaginary component of phasor coordinates.
54
- sigma: float, default = 2.0
54
+ sigma : float, optional
55
55
  Scaling factor for radii of major and minor axes.
56
- Defaults to 2, which corresponds to the scaling of eigenvalues for a
57
- 95% confidence ellipse.
56
+ Defaults to 2.0, which corresponds to the scaling of eigenvalues for
57
+ a 95% confidence ellipse.
58
58
  clusters : int, optional
59
59
  Number of Gaussian distributions to fit to phasor coordinates.
60
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
+
61
68
  **kwargs
62
69
  Additional keyword arguments passed to
63
70
  :py:class:`sklearn.mixture.GaussianMixture`.
@@ -81,19 +88,12 @@ def phasor_cluster_gmm(
81
88
  angle : tuple of float
82
89
  Rotation angles of major axes in radians, within range [0, pi].
83
90
 
84
- Raises
85
- ------
86
- ValueError
87
- If the array shapes of `real` and `imag` do not match.
88
- If `clusters` is not a positive integer.
89
-
90
-
91
91
  References
92
92
  ----------
93
93
  .. [1] Vallmitjana A, Torrado B, and Gratton E.
94
94
  `Phasor-based image segmentation: machine learning clustering techniques
95
95
  <https://doi.org/10.1364/BOE.422766>`_.
96
- *Biomed Opt Express*, 12(6): 3410-3422 (2021).
96
+ *Biomed Opt Express*, 12(6): 3410-3422 (2021)
97
97
 
98
98
  Examples
99
99
  --------
@@ -111,11 +111,13 @@ def phasor_cluster_gmm(
111
111
  >>> center_real, center_imag, radius_major, radius_minor, angle = (
112
112
  ... phasor_cluster_gmm(real, imag, clusters=2)
113
113
  ... )
114
- >>> centers_real # doctest: +SKIP
114
+ >>> center_real # doctest: +SKIP
115
115
  (0.2, 0.4)
116
116
 
117
117
  """
118
- coords = numpy.stack((real, imag), axis=-1).reshape(-1, 2)
118
+ from sklearn.mixture import GaussianMixture
119
+
120
+ coords = numpy.stack([real, imag], axis=-1).reshape(-1, 2)
119
121
 
120
122
  valid_data = ~numpy.isnan(coords).any(axis=1)
121
123
  coords = coords[valid_data]
@@ -161,10 +163,38 @@ def phasor_cluster_gmm(
161
163
  radius_major.append(sigma * math.sqrt(2 * eigenvalues[0]))
162
164
  radius_minor.append(sigma * math.sqrt(2 * eigenvalues[1]))
163
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
+
164
194
  return (
165
- tuple(center_real),
166
- tuple(center_imag),
167
- tuple(radius_major),
168
- tuple(radius_minor),
169
- tuple(angle),
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),
170
200
  )
phasorpy/color.py CHANGED
@@ -1,4 +1,4 @@
1
- """Color palettes and manipulation."""
1
+ """Color palettes and color manipulation utilities."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -19,20 +19,22 @@ def wavelength2rgb(
19
19
  ) -> tuple[float, float, float] | NDArray[Any]:
20
20
  """Return approximate sRGB color components of visible wavelength(s).
21
21
 
22
- Wavelengths are clipped to range [360, 750] nm, rounded, and used to
22
+ Wavelengths are clipped to the range [360, 750] nm, rounded, and used to
23
23
  index the :py:attr:`SRGB_SPECTRUM` palette.
24
24
 
25
25
  Parameters
26
26
  ----------
27
27
  wavelength : array_like
28
28
  Scalar or array of wavelengths in nm.
29
- dtype : data-type, optional
29
+ dtype : dtype_like, optional
30
30
  Data-type of return value. The default is ``float32``.
31
31
 
32
32
  Returns
33
33
  -------
34
- ndarray or tuple
35
- Approximate sRGB color components of visible wavelength.
34
+ ndarray or tuple of float
35
+ Approximate sRGB color components of visible wavelength(s).
36
+ If input is scalar, return tuple of three floats.
37
+ If input is array, return ndarray with shape (..., 3).
36
38
  Floating-point values are in range [0.0, 1.0].
37
39
  Integer values are scaled to the dtype's maximum value.
38
40
 
@@ -74,7 +76,7 @@ def float2int(
74
76
  ----------
75
77
  rgb : array_like
76
78
  Scalar or array of normalized floating-point color components.
77
- dtype : data-type, optional
79
+ dtype : dtype_like, optional
78
80
  Data type of return value. The default is ``uint8``.
79
81
 
80
82
  Returns
@@ -169,7 +171,7 @@ CATEGORICAL: NDArray[numpy.float32] = numpy.array([
169
171
  ], dtype=numpy.float32)
170
172
  """Categorical sRGB color palette inspired by C. Glasbey.
171
173
 
172
- Contains 64 maximally distinct colours for visualization.
174
+ Contains 64 maximally distinct colors for visualization.
173
175
 
174
176
  Generated using the `glasbey <https://glasbey.readthedocs.io>`_ package::
175
177
 
@@ -575,6 +577,8 @@ SRGB_SPECTRUM: NDArray[numpy.float32] = numpy.array([
575
577
  ], dtype=numpy.float32)
576
578
  """sRGB color components for wavelengths of visible light (360-750 nm).
577
579
 
580
+ Array of shape (391, 3) containing normalized sRGB color components
581
+ for wavelengths from 360 to 750 nm in 1 nm increments.
578
582
  Based on the CIE 1931 2° Standard Observer.
579
583
 
580
584
  Generated using the `colour <https://colour.readthedocs.io>`_ package::