phasorpy 0.4__cp313-cp313-win_arm64.whl → 0.5__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.
Binary file
phasorpy/_phasorpy.pyx CHANGED
@@ -4,15 +4,10 @@
4
4
  # cython: wraparound = False
5
5
  # cython: cdivision = True
6
6
  # cython: nonecheck = False
7
+ # cython: freethreading_compatible = True
7
8
 
8
9
  """Cython implementation of low-level functions for the PhasorPy library."""
9
10
 
10
- # TODO: replace short with unsigned char when Cython supports it
11
- # https://github.com/cython/cython/pull/6196#issuecomment-2209509572
12
-
13
- # TODO: use fused return types for functions returning more than two items
14
- # https://github.com/cython/cython/issues/6328
15
-
16
11
  cimport cython
17
12
 
18
13
  from cython.parallel import parallel, prange
@@ -451,7 +446,7 @@ cdef (double, double) _phasor_from_fret_donor(
451
446
  double omega,
452
447
  double donor_lifetime,
453
448
  double fret_efficiency,
454
- double donor_freting,
449
+ double donor_fretting,
455
450
  double donor_background,
456
451
  double background_real,
457
452
  double background_imag,
@@ -471,16 +466,16 @@ cdef (double, double) _phasor_from_fret_donor(
471
466
  elif fret_efficiency > 1.0:
472
467
  fret_efficiency = 1.0
473
468
 
474
- if donor_freting < 0.0:
475
- donor_freting = 0.0
476
- elif donor_freting > 1.0:
477
- donor_freting = 1.0
469
+ if donor_fretting < 0.0:
470
+ donor_fretting = 0.0
471
+ elif donor_fretting > 1.0:
472
+ donor_fretting = 1.0
478
473
 
479
474
  if donor_background < 0.0:
480
475
  donor_background = 0.0
481
476
 
482
- f_pure = 1.0 - donor_freting
483
- f_quenched = (1.0 - fret_efficiency) * donor_freting
477
+ f_pure = 1.0 - donor_fretting
478
+ f_quenched = (1.0 - fret_efficiency) * donor_fretting
484
479
  sum = f_pure + f_quenched + donor_background
485
480
  if sum < 1e-9:
486
481
  # no signal in donor channel
@@ -516,7 +511,7 @@ cdef (double, double) _phasor_from_fret_acceptor(
516
511
  double donor_lifetime,
517
512
  double acceptor_lifetime,
518
513
  double fret_efficiency,
519
- double donor_freting,
514
+ double donor_fretting,
520
515
  double donor_bleedthrough,
521
516
  double acceptor_bleedthrough,
522
517
  double acceptor_background,
@@ -541,10 +536,10 @@ cdef (double, double) _phasor_from_fret_acceptor(
541
536
  elif fret_efficiency > 1.0:
542
537
  fret_efficiency = 1.0
543
538
 
544
- if donor_freting < 0.0:
545
- donor_freting = 0.0
546
- elif donor_freting > 1.0:
547
- donor_freting = 1.0
539
+ if donor_fretting < 0.0:
540
+ donor_fretting = 0.0
541
+ elif donor_fretting > 1.0:
542
+ donor_fretting = 1.0
548
543
 
549
544
  if donor_bleedthrough < 0.0:
550
545
  donor_bleedthrough = 0.0
@@ -575,7 +570,7 @@ cdef (double, double) _phasor_from_fret_acceptor(
575
570
  quenched_imag,
576
571
  1.0,
577
572
  1.0 - fret_efficiency,
578
- 1.0 - donor_freting
573
+ 1.0 - donor_fretting
579
574
  )
580
575
 
581
576
  # phasor of acceptor at frequency
@@ -597,8 +592,8 @@ cdef (double, double) _phasor_from_fret_acceptor(
597
592
  sensitized_imag = mod * sin(phi)
598
593
 
599
594
  # weighted average
600
- f_donor = donor_bleedthrough * (1.0 - donor_freting * fret_efficiency)
601
- f_acceptor = donor_freting * fret_efficiency
595
+ f_donor = donor_bleedthrough * (1.0 - donor_fretting * fret_efficiency)
596
+ f_acceptor = donor_fretting * fret_efficiency
602
597
  sum = f_donor + f_acceptor + acceptor_bleedthrough + acceptor_background
603
598
  if sum < 1e-9:
604
599
  # no signal in acceptor channel
@@ -919,7 +914,7 @@ cdef (float_t, float_t) _phasor_at_harmonic(
919
914
  int harmonic,
920
915
  int other_harmonic,
921
916
  ) noexcept nogil:
922
- """Return phasor coordinates on semicircle at other harmonic."""
917
+ """Return phasor coordinates on universal semicircle at other harmonic."""
923
918
  if isnan(real):
924
919
  return <float_t> NAN, <float_t> NAN
925
920
 
@@ -982,7 +977,7 @@ cdef (float_t, float_t) _phasor_divide(
982
977
 
983
978
 
984
979
  @cython.ufunc
985
- cdef short _is_inside_range(
980
+ cdef unsigned char _is_inside_range(
986
981
  float_t x, # point
987
982
  float_t y,
988
983
  float_t xmin, # x range
@@ -1002,7 +997,7 @@ cdef short _is_inside_range(
1002
997
 
1003
998
 
1004
999
  @cython.ufunc
1005
- cdef short _is_inside_rectangle(
1000
+ cdef unsigned char _is_inside_rectangle(
1006
1001
  float_t x, # point
1007
1002
  float_t y,
1008
1003
  float_t x0, # segment start
@@ -1044,7 +1039,7 @@ cdef short _is_inside_rectangle(
1044
1039
 
1045
1040
 
1046
1041
  @cython.ufunc
1047
- cdef short _is_inside_polar_rectangle(
1042
+ cdef unsigned char _is_inside_polar_rectangle(
1048
1043
  float_t x, # point
1049
1044
  float_t y,
1050
1045
  float_t angle_min, # phase, -pi to pi
@@ -1054,7 +1049,7 @@ cdef short _is_inside_polar_rectangle(
1054
1049
  ) noexcept nogil:
1055
1050
  """Return whether point is inside polar rectangle.
1056
1051
 
1057
- Angles should be in range -pi to pi, else performance is degraded.
1052
+ Angles should be in range [-pi, pi], else performance is degraded.
1058
1053
 
1059
1054
  """
1060
1055
  cdef:
@@ -1083,7 +1078,7 @@ cdef short _is_inside_polar_rectangle(
1083
1078
 
1084
1079
 
1085
1080
  @cython.ufunc
1086
- cdef short _is_inside_circle(
1081
+ cdef unsigned char _is_inside_circle(
1087
1082
  float_t x, # point
1088
1083
  float_t y,
1089
1084
  float_t x0, # circle center
@@ -1100,7 +1095,7 @@ cdef short _is_inside_circle(
1100
1095
 
1101
1096
 
1102
1097
  @cython.ufunc
1103
- cdef short _is_inside_ellipse(
1098
+ cdef unsigned char _is_inside_ellipse(
1104
1099
  float_t x, # point
1105
1100
  float_t y,
1106
1101
  float_t x0, # ellipse center
@@ -1135,7 +1130,7 @@ cdef short _is_inside_ellipse(
1135
1130
 
1136
1131
 
1137
1132
  @cython.ufunc
1138
- cdef short _is_inside_ellipse_(
1133
+ cdef unsigned char _is_inside_ellipse_(
1139
1134
  float_t x, # point
1140
1135
  float_t y,
1141
1136
  float_t x0, # ellipse center
@@ -1164,7 +1159,7 @@ cdef short _is_inside_ellipse_(
1164
1159
 
1165
1160
 
1166
1161
  @cython.ufunc
1167
- cdef short _is_inside_stadium(
1162
+ cdef unsigned char _is_inside_stadium(
1168
1163
  float_t x, # point
1169
1164
  float_t y,
1170
1165
  float_t x0, # line start
@@ -1210,7 +1205,7 @@ _is_near_segment = _is_inside_stadium
1210
1205
 
1211
1206
 
1212
1207
  @cython.ufunc
1213
- cdef short _is_near_line(
1208
+ cdef unsigned char _is_near_line(
1214
1209
  float_t x, # point
1215
1210
  float_t y,
1216
1211
  float_t x0, # line start
@@ -1476,7 +1471,7 @@ cdef float_t _distance_from_line(
1476
1471
 
1477
1472
 
1478
1473
  @cython.ufunc
1479
- cdef (double, double, double) _segment_direction_and_length(
1474
+ cdef (float_t, float_t, float_t) _segment_direction_and_length(
1480
1475
  float_t x0, # segment start
1481
1476
  float_t y0,
1482
1477
  float_t x1, # segment end
@@ -1500,7 +1495,7 @@ cdef (double, double, double) _segment_direction_and_length(
1500
1495
 
1501
1496
 
1502
1497
  @cython.ufunc
1503
- cdef (double, double, double, double) _intersection_circle_circle(
1498
+ cdef (float_t, float_t, float_t, float_t) _intersection_circle_circle(
1504
1499
  float_t x0, # circle 0
1505
1500
  float_t y0,
1506
1501
  float_t r0,
@@ -1538,15 +1533,15 @@ cdef (double, double, double, double) _intersection_circle_circle(
1538
1533
  hd = sqrt(dd) / dr
1539
1534
  ld = ll / dr
1540
1535
  return (
1541
- ld * dx + hd * dy + x0,
1542
- ld * dy - hd * dx + y0,
1543
- ld * dx - hd * dy + x0,
1544
- ld * dy + hd * dx + y0,
1536
+ <float_t> (ld * dx + hd * dy + x0),
1537
+ <float_t> (ld * dy - hd * dx + y0),
1538
+ <float_t> (ld * dx - hd * dy + x0),
1539
+ <float_t> (ld * dy + hd * dx + y0),
1545
1540
  )
1546
1541
 
1547
1542
 
1548
1543
  @cython.ufunc
1549
- cdef (double, double, double, double) _intersection_circle_line(
1544
+ cdef (float_t, float_t, float_t, float_t) _intersection_circle_line(
1550
1545
  float_t x, # circle
1551
1546
  float_t y,
1552
1547
  float_t r,
@@ -1581,10 +1576,10 @@ cdef (double, double, double, double) _intersection_circle_line(
1581
1576
  return NAN, NAN, NAN, NAN
1582
1577
  rdd = sqrt(rdd)
1583
1578
  return (
1584
- x + (dd * dy + copysign(1.0, dy) * dx * rdd) / dr,
1585
- y + (-dd * dx + fabs(dy) * rdd) / dr,
1586
- x + (dd * dy - copysign(1.0, dy) * dx * rdd) / dr,
1587
- y + (-dd * dx - fabs(dy) * rdd) / dr,
1579
+ x + <float_t> ((dd * dy + copysign(1.0, dy) * dx * rdd) / dr),
1580
+ y + <float_t> ((-dd * dx + fabs(dy) * rdd) / dr),
1581
+ x + <float_t> ((dd * dy - copysign(1.0, dy) * dx * rdd) / dr),
1582
+ y + <float_t> ((-dd * dx - fabs(dy) * rdd) / dr),
1588
1583
  )
1589
1584
 
1590
1585
 
@@ -1665,7 +1660,7 @@ cdef float_t _blend_lighten(
1665
1660
 
1666
1661
 
1667
1662
  @cython.ufunc
1668
- cdef (double, double, double) _phasor_threshold_open(
1663
+ cdef (float_t, float_t, float_t) _phasor_threshold_open(
1669
1664
  float_t mean,
1670
1665
  float_t real,
1671
1666
  float_t imag,
@@ -1727,7 +1722,7 @@ cdef (double, double, double) _phasor_threshold_open(
1727
1722
 
1728
1723
 
1729
1724
  @cython.ufunc
1730
- cdef (double, double, double) _phasor_threshold_closed(
1725
+ cdef (float_t, float_t, float_t) _phasor_threshold_closed(
1731
1726
  float_t mean,
1732
1727
  float_t real,
1733
1728
  float_t imag,
@@ -1789,7 +1784,7 @@ cdef (double, double, double) _phasor_threshold_closed(
1789
1784
 
1790
1785
 
1791
1786
  @cython.ufunc
1792
- cdef (double, double, double) _phasor_threshold_mean_open(
1787
+ cdef (float_t, float_t, float_t) _phasor_threshold_mean_open(
1793
1788
  float_t mean,
1794
1789
  float_t real,
1795
1790
  float_t imag,
@@ -1809,7 +1804,7 @@ cdef (double, double, double) _phasor_threshold_mean_open(
1809
1804
 
1810
1805
 
1811
1806
  @cython.ufunc
1812
- cdef (double, double, double) _phasor_threshold_mean_closed(
1807
+ cdef (float_t, float_t, float_t) _phasor_threshold_mean_closed(
1813
1808
  float_t mean,
1814
1809
  float_t real,
1815
1810
  float_t imag,
@@ -1829,7 +1824,7 @@ cdef (double, double, double) _phasor_threshold_mean_closed(
1829
1824
 
1830
1825
 
1831
1826
  @cython.ufunc
1832
- cdef (double, double, double) _phasor_threshold_nan(
1827
+ cdef (float_t, float_t, float_t) _phasor_threshold_nan(
1833
1828
  float_t mean,
1834
1829
  float_t real,
1835
1830
  float_t imag,
@@ -2171,6 +2166,7 @@ def _median_filter_2d(
2171
2166
  # Decoder functions
2172
2167
 
2173
2168
 
2169
+ @cython.boundscheck(True)
2174
2170
  def _flimlabs_signal(
2175
2171
  uint_t[:, :, ::] signal, # channel, pixel, bin
2176
2172
  list data, # list[list[list[[int, int]]]]
@@ -2178,6 +2174,7 @@ def _flimlabs_signal(
2178
2174
  ):
2179
2175
  """Return TCSPC histogram image from FLIM LABS JSON intensity data."""
2180
2176
  cdef:
2177
+ uint_t[::] signal_
2181
2178
  list channels, pixels
2182
2179
  ssize_t c, i, h, count
2183
2180
 
@@ -2186,18 +2183,21 @@ def _flimlabs_signal(
2186
2183
  for channels in data:
2187
2184
  i = 0
2188
2185
  for pixels in channels:
2186
+ signal_ = signal[c, i]
2189
2187
  for h, count in pixels:
2190
- signal[c, i, h] = <uint_t> count
2188
+ signal_[h] = <uint_t> count
2191
2189
  i += 1
2192
2190
  c += 1
2193
2191
  else:
2194
2192
  i = 0
2195
2193
  for pixels in data[channel]:
2194
+ signal_ = signal[0, i]
2196
2195
  for h, count in pixels:
2197
- signal[0, i, h] = <uint_t> count
2196
+ signal_[h] = <uint_t> count
2198
2197
  i += 1
2199
2198
 
2200
2199
 
2200
+ @cython.boundscheck(True)
2201
2201
  def _flimlabs_mean(
2202
2202
  float_t[:, ::] mean, # channel, pixel
2203
2203
  list data, # list[list[list[[int, int]]]]
@@ -2205,6 +2205,7 @@ def _flimlabs_mean(
2205
2205
  ):
2206
2206
  """Return mean intensity image from FLIM LABS JSON intensity data."""
2207
2207
  cdef:
2208
+ float_t[::] mean_
2208
2209
  list channels, pixels
2209
2210
  ssize_t c, i, h, count
2210
2211
  double sum
@@ -2212,19 +2213,21 @@ def _flimlabs_mean(
2212
2213
  if channel < 0:
2213
2214
  c = 0
2214
2215
  for channels in data:
2216
+ mean_ = mean[c]
2215
2217
  i = 0
2216
2218
  for pixels in channels:
2217
2219
  sum = 0.0
2218
2220
  for h, count in pixels:
2219
2221
  sum += <double> count
2220
- mean[c, i] = <float_t> (sum / 255.0)
2222
+ mean_[i] = <float_t> (sum / 256.0)
2221
2223
  i += 1
2222
2224
  c += 1
2223
2225
  else:
2224
2226
  i = 0
2227
+ mean_ = mean[0]
2225
2228
  for pixels in data[channel]:
2226
2229
  sum = 0.0
2227
2230
  for h, count in pixels:
2228
2231
  sum += <double> count
2229
- mean[0, i] = <float_t> (sum / 255.0)
2232
+ mean_[i] = <float_t> (sum / 256.0)
2230
2233
  i += 1
phasorpy/_utils.py CHANGED
@@ -2,26 +2,29 @@
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
8
  'kwargs_notnone',
9
9
  'parse_harmonic',
10
10
  'parse_kwargs',
11
11
  'parse_signal_axis',
12
+ 'parse_skip_axis',
12
13
  'phasor_from_polar_scalar',
13
14
  'phasor_to_polar_scalar',
14
15
  'scale_matrix',
16
+ 'set_module',
15
17
  'sort_coordinates',
16
18
  'update_kwargs',
17
19
  ]
18
20
 
19
21
  import math
20
22
  import numbers
23
+ from collections.abc import Sequence
21
24
  from typing import TYPE_CHECKING
22
25
 
23
26
  if TYPE_CHECKING:
24
- from ._typing import Any, Sequence, ArrayLike, Literal, NDArray, Iterator
27
+ from ._typing import Any, ArrayLike, Literal, NDArray, Iterator
25
28
 
26
29
  import numpy
27
30
 
@@ -268,7 +271,7 @@ def parse_signal_axis(
268
271
  -------
269
272
  axis : int
270
273
  Axis over which phasor coordinates are computed.
271
- axis_label: str
274
+ axis_label : str
272
275
  Axis label from `signal.dims` if any.
273
276
 
274
277
  Raises
@@ -312,6 +315,65 @@ def parse_signal_axis(
312
315
  raise ValueError(f'{axis=} not valid for {type(signal)=}')
313
316
 
314
317
 
318
+ def parse_skip_axis(
319
+ skip_axis: int | Sequence[int] | None,
320
+ /,
321
+ ndim: int,
322
+ prepend_axis: bool = False,
323
+ ) -> tuple[tuple[int, ...], tuple[int, ...]]:
324
+ """Return axes to skip and not to skip.
325
+
326
+ This helper function is used to validate and parse `skip_axis`
327
+ parameters.
328
+
329
+ Parameters
330
+ ----------
331
+ skip_axis : int or sequence of int, optional
332
+ Axes to skip. If None, no axes are skipped.
333
+ ndim : int
334
+ Dimensionality of array in which to skip axes.
335
+ prepend_axis : bool, optional
336
+ Prepend one dimension and include in `skip_axis`.
337
+
338
+ Returns
339
+ -------
340
+ skip_axis : tuple of int
341
+ Ordered, positive values of `skip_axis`.
342
+ other_axis : tuple of int
343
+ Axes indices not included in `skip_axis`.
344
+
345
+ Raises
346
+ ------
347
+ IndexError
348
+ If any `skip_axis` value is out of bounds of `ndim`.
349
+
350
+ Examples
351
+ --------
352
+ >>> parse_skip_axis((1, -2), 5)
353
+ ((1, 3), (0, 2, 4))
354
+
355
+ >>> parse_skip_axis((1, -2), 5, True)
356
+ ((0, 2, 4), (1, 3, 5))
357
+
358
+ """
359
+ if ndim < 0:
360
+ raise ValueError(f'invalid {ndim=}')
361
+ if skip_axis is None:
362
+ if prepend_axis:
363
+ return (0,), tuple(range(1, ndim + 1))
364
+ return (), tuple(range(ndim))
365
+ if not isinstance(skip_axis, Sequence):
366
+ skip_axis = (skip_axis,)
367
+ if any(i >= ndim or i < -ndim for i in skip_axis):
368
+ raise IndexError(f'skip_axis={skip_axis} out of range for {ndim=}')
369
+ skip_axis = sorted(int(i % ndim) for i in skip_axis)
370
+ if prepend_axis:
371
+ skip_axis = [0] + [i + 1 for i in skip_axis]
372
+ ndim += 1
373
+ other_axis = tuple(i for i in range(ndim) if i not in skip_axis)
374
+ return tuple(skip_axis), other_axis
375
+
376
+
315
377
  def parse_harmonic(
316
378
  harmonic: int | Sequence[int] | Literal['all'] | str | None,
317
379
  harmonic_max: int | None = None,
@@ -343,7 +405,7 @@ def parse_harmonic(
343
405
  Raises
344
406
  ------
345
407
  IndexError
346
- Any element is out of range `[1..harmonic_max]`.
408
+ Any element is out of range `[1, harmonic_max]`.
347
409
  ValueError
348
410
  Elements are not unique.
349
411
  Harmonic is empty.
@@ -364,7 +426,7 @@ def parse_harmonic(
364
426
  if harmonic < 1 or (
365
427
  harmonic_max is not None and harmonic > harmonic_max
366
428
  ):
367
- raise IndexError(f'{harmonic=} out of range [1..{harmonic_max}]')
429
+ raise IndexError(f'{harmonic=} out of range [1, {harmonic_max}]')
368
430
  return [int(harmonic)], False
369
431
 
370
432
  if isinstance(harmonic, str):
@@ -376,7 +438,7 @@ def parse_harmonic(
376
438
  return list(range(1, harmonic_max + 1)), True
377
439
  raise ValueError(f'{harmonic=!r} is not a valid harmonic')
378
440
 
379
- h = numpy.atleast_1d(numpy.asarray(harmonic))
441
+ h = numpy.atleast_1d(harmonic)
380
442
  if h.size == 0:
381
443
  raise ValueError(f'{harmonic=} is empty')
382
444
  if h.dtype.kind not in 'iu' or h.ndim != 1:
@@ -387,7 +449,7 @@ def parse_harmonic(
387
449
  raise IndexError(f'{harmonic=} element > {harmonic_max}]')
388
450
  if numpy.unique(h).size != h.size:
389
451
  raise ValueError(f'{harmonic=} elements must be unique')
390
- return h.tolist(), True
452
+ return [int(i) for i in harmonic], True
391
453
 
392
454
 
393
455
  def chunk_iter(
@@ -517,3 +579,23 @@ def chunk_iter(
517
579
  for i in range(ndim)
518
580
  ),
519
581
  )
582
+
583
+
584
+ def set_module(globs: dict[str, Any], /) -> None:
585
+ """Set ``__module__`` attribute for objects in ``__all__``.
586
+
587
+ Parameters
588
+ ----------
589
+ globs : dict
590
+ Module namespace to modify.
591
+
592
+ Examples
593
+ --------
594
+ >>> set_module(globals())
595
+
596
+ """
597
+ name = globs['__name__']
598
+ for item in globs['__all__']:
599
+ obj = globs[item]
600
+ if hasattr(obj, '__module__'):
601
+ obj.__module__ = name
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
 
phasorpy/cluster.py ADDED
@@ -0,0 +1,170 @@
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
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
+ **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, default = 2.0
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.
58
+ clusters : int, optional
59
+ Number of Gaussian distributions to fit to phasor coordinates.
60
+ Defaults to 1.
61
+ **kwargs
62
+ Additional keyword arguments passed to
63
+ :py:class:`sklearn.mixture.GaussianMixture`.
64
+
65
+ Common options include:
66
+
67
+ - covariance_type : {'full', 'tied', 'diag', 'spherical'}
68
+ - max_iter : int, maximum number of EM iterations
69
+ - random_state : int, for reproducible results
70
+
71
+ Returns
72
+ -------
73
+ center_real : tuple of float
74
+ Real component of ellipse centers.
75
+ center_imag : tuple of float
76
+ Imaginary component of ellipse centers.
77
+ radius_major : tuple of float
78
+ Major radii of ellipses.
79
+ radius_minor : tuple of float
80
+ Minor radii of ellipses.
81
+ angle : tuple of float
82
+ Rotation angles of major axes in radians, within range [0, pi].
83
+
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
+ 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
+ >>> centers_real # doctest: +SKIP
115
+ (0.2, 0.4)
116
+
117
+ """
118
+ coords = numpy.stack((real, imag), axis=-1).reshape(-1, 2)
119
+
120
+ valid_data = ~numpy.isnan(coords).any(axis=1)
121
+ coords = coords[valid_data]
122
+
123
+ kwargs.pop('n_components', None)
124
+
125
+ gmm = GaussianMixture(n_components=clusters, **kwargs)
126
+ gmm.fit(coords)
127
+
128
+ center_real = []
129
+ center_imag = []
130
+ radius_major = []
131
+ radius_minor = []
132
+ angle = []
133
+
134
+ for i in range(clusters):
135
+ center_real.append(float(gmm.means_[i, 0]))
136
+ center_imag.append(float(gmm.means_[i, 1]))
137
+
138
+ if gmm.covariance_type == 'full':
139
+ cov = gmm.covariances_[i]
140
+ elif gmm.covariance_type == 'tied':
141
+ cov = gmm.covariances_
142
+ elif gmm.covariance_type == 'diag':
143
+ cov = numpy.diag(gmm.covariances_[i])
144
+ else: # 'spherical'
145
+ cov = numpy.eye(2) * gmm.covariances_[i]
146
+
147
+ eigenvalues, eigenvectors = numpy.linalg.eigh(cov[:2, :2])
148
+
149
+ idx = eigenvalues.argsort()[::-1]
150
+ eigenvalues = eigenvalues[idx]
151
+ eigenvectors = eigenvectors[:, idx]
152
+
153
+ major_vector = eigenvectors[:, 0]
154
+ current_angle = math.atan2(major_vector[1], major_vector[0])
155
+
156
+ if current_angle < 0:
157
+ current_angle += math.pi
158
+
159
+ angle.append(float(current_angle))
160
+
161
+ radius_major.append(sigma * math.sqrt(2 * eigenvalues[0]))
162
+ radius_minor.append(sigma * math.sqrt(2 * eigenvalues[1]))
163
+
164
+ return (
165
+ tuple(center_real),
166
+ tuple(center_imag),
167
+ tuple(radius_major),
168
+ tuple(radius_minor),
169
+ tuple(angle),
170
+ )