phasorpy 0.4__cp313-cp313-win_arm64.whl → 0.6__cp313-cp313-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
@@ -2,26 +2,41 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __all__: list[str] = [
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 Any, Sequence, ArrayLike, Literal, NDArray, Iterator
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..harmonic_max]`.
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..{harmonic_max}]')
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(numpy.asarray(harmonic))
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 h.tolist(), True
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 version
21
+ from . import __version__
20
22
 
21
23
 
22
24
  @click.group(help='PhasorPy package command line interface.')
23
- @click.version_option(version=version.__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
- click.echo(version.versions(verbose=verbose))
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
- Wavelength values are clipped to 360..750, rounded, and used to index
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 wavelength(s) to convert.
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 types are in range 0.0 to 1.0.
37
- Integer types are scaled to the dtype's maximum value.
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 integer type.
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
- The palette contains 64 maximally distinct colours.
172
+ Contains 64 maximally distinct colours for visualization.
168
173
 
169
- Generated with the `glasbey <https://glasbey.readthedocs.io>`_ package::
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 wavelengths 360-750 nm.
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 with the `colour <https://colour.readthedocs.io>`_ package::
580
+ Generated using the `colour <https://colour.readthedocs.io>`_ package::
576
581
 
577
582
  import colour; colour.plotting.plot_visible_spectrum()
578
583