phasorpy 0.4__cp311-cp311-win_arm64.whl → 0.6__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/components.py CHANGED
@@ -3,31 +3,29 @@
3
3
  The ``phasorpy.components`` module provides functions to:
4
4
 
5
5
  - calculate fractions of two known components by projecting onto the
6
- line between the components:
7
-
8
- - :py:func:`two_fractions_from_phasor`
6
+ line between the components (:py:func:`phasor_component_fraction`)
9
7
 
10
8
  - calculate phasor coordinates of second component if only one is
11
9
  known (not implemented)
12
10
 
13
- - calculate fractions of three or four known components by using higher
14
- harmonic information (not implemented)
11
+ - calculate fractions of multiple known components by using higher
12
+ harmonic information (:py:func:`phasor_component_fit`)
15
13
 
16
14
  - calculate fractions of two or three known components by resolving
17
- graphically with histogram:
18
-
19
- - :py:func:`graphical_component_analysis`
15
+ graphically with histogram (:py:func:`phasor_component_graphical`)
20
16
 
21
- - blindly resolve fractions of `n` components by using harmonic
22
- information (not implemented)
17
+ - blindly resolve fractions of multiple components by using harmonic
18
+ information (:py:func:`phasor_component_blind`, not implemented)
23
19
 
24
20
  """
25
21
 
26
22
  from __future__ import annotations
27
23
 
28
24
  __all__ = [
29
- 'two_fractions_from_phasor',
30
- 'graphical_component_analysis',
25
+ # phasor_component_blind,
26
+ 'phasor_component_fit',
27
+ 'phasor_component_fraction',
28
+ 'phasor_component_graphical',
31
29
  ]
32
30
 
33
31
  import numbers
@@ -39,18 +37,20 @@ if TYPE_CHECKING:
39
37
  import numpy
40
38
 
41
39
  from ._phasorpy import (
40
+ _blend_and,
42
41
  _fraction_on_segment,
43
42
  _is_inside_circle,
44
43
  _is_inside_stadium,
45
44
  _segment_direction_and_length,
46
45
  )
46
+ from .phasor import phasor_threshold
47
47
 
48
48
 
49
- def two_fractions_from_phasor(
49
+ def phasor_component_fraction(
50
50
  real: ArrayLike,
51
51
  imag: ArrayLike,
52
- components_real: ArrayLike,
53
- components_imag: ArrayLike,
52
+ component_real: ArrayLike,
53
+ component_imag: ArrayLike,
54
54
  /,
55
55
  ) -> NDArray[Any]:
56
56
  """Return fraction of first of two components from phasor coordinates.
@@ -65,10 +65,10 @@ def two_fractions_from_phasor(
65
65
  Real component of phasor coordinates.
66
66
  imag : array_like
67
67
  Imaginary component of phasor coordinates.
68
- components_real: array_like, shape (2,)
69
- Real coordinates of the first and second components.
70
- components_imag: array_like, shape (2,)
71
- Imaginary coordinates of the first and second components.
68
+ component_real : array_like, shape (2,)
69
+ Real coordinates of first and second components.
70
+ component_imag : array_like, shape (2,)
71
+ Imaginary coordinates of first and second components.
72
72
 
73
73
  Returns
74
74
  -------
@@ -78,7 +78,7 @@ def two_fractions_from_phasor(
78
78
  Raises
79
79
  ------
80
80
  ValueError
81
- If the real and/or imaginary coordinates of the known components are
81
+ If the real or imaginary coordinates of the known components are
82
82
  not of size 2.
83
83
 
84
84
  See Also
@@ -95,39 +95,39 @@ def two_fractions_from_phasor(
95
95
 
96
96
  Examples
97
97
  --------
98
- >>> two_fractions_from_phasor(
98
+ >>> phasor_component_fraction(
99
99
  ... [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.9], [0.4, 0.3]
100
100
  ... ) # doctest: +NUMBER
101
101
  array([0.44, 0.56, 0.68])
102
102
 
103
103
  """
104
- components_real = numpy.asarray(components_real)
105
- components_imag = numpy.asarray(components_imag)
106
- if components_real.shape != (2,):
107
- raise ValueError(f'{components_real.shape=} != (2,)')
108
- if components_imag.shape != (2,):
109
- raise ValueError(f'{components_imag.shape=} != (2,)')
104
+ component_real = numpy.asarray(component_real)
105
+ component_imag = numpy.asarray(component_imag)
106
+ if component_real.shape != (2,):
107
+ raise ValueError(f'{component_real.shape=} != (2,)')
108
+ if component_imag.shape != (2,):
109
+ raise ValueError(f'{component_imag.shape=} != (2,)')
110
110
  if (
111
- components_real[0] == components_real[1]
112
- and components_imag[0] == components_imag[1]
111
+ component_real[0] == component_real[1]
112
+ and component_imag[0] == component_imag[1]
113
113
  ):
114
114
  raise ValueError('components must have different coordinates')
115
115
 
116
116
  return _fraction_on_segment( # type: ignore[no-any-return]
117
117
  real,
118
118
  imag,
119
- components_real[0],
120
- components_imag[0],
121
- components_real[1],
122
- components_imag[1],
119
+ component_real[0],
120
+ component_imag[0],
121
+ component_real[1],
122
+ component_imag[1],
123
123
  )
124
124
 
125
125
 
126
- def graphical_component_analysis(
126
+ def phasor_component_graphical(
127
127
  real: ArrayLike,
128
128
  imag: ArrayLike,
129
- components_real: ArrayLike,
130
- components_imag: ArrayLike,
129
+ component_real: ArrayLike,
130
+ component_imag: ArrayLike,
131
131
  /,
132
132
  *,
133
133
  radius: float = 0.05,
@@ -145,13 +145,13 @@ def graphical_component_analysis(
145
145
  Real component of phasor coordinates.
146
146
  imag : array_like
147
147
  Imaginary component of phasor coordinates.
148
- components_real: array_like, shape (2,) or (3,)
148
+ component_real : array_like, shape (2,) or (3,)
149
149
  Real coordinates for two or three components.
150
- components_imag: array_like, shape (2,) or (3,)
150
+ component_imag : array_like, shape (2,) or (3,)
151
151
  Imaginary coordinates for two or three components.
152
- radius: float, optional, default: 0.05
153
- Radius of the cursor in phasor coordinates.
154
- fractions: array_like or int, optional
152
+ radius : float, optional, default: 0.05
153
+ Radius of cursor.
154
+ fractions : array_like or int, optional
155
155
  Number of equidistant fractions, or 1D array of fraction values.
156
156
  Fraction values must be in range [0.0, 1.0].
157
157
  If an integer, ``numpy.linspace(0.0, 1.0, fractions)`` fraction values
@@ -163,16 +163,16 @@ def graphical_component_analysis(
163
163
  Returns
164
164
  -------
165
165
  counts : tuple of ndarray
166
- Counts along each line segment connecting the components, ordered
167
- 0-1 (2 components) or 0-1, 0-2, 1-2 (3 components).
166
+ Counts along each line segment connecting components.
167
+ Ordered 0-1 (2 components) or 0-1, 0-2, 1-2 (3 components).
168
168
 
169
169
  Raises
170
170
  ------
171
171
  ValueError
172
- The array shapes of `real` and `imag`, or `components_real` and
173
- `components_imag` do not match.
172
+ The array shapes of `real` and `imag`, or `component_real` and
173
+ `component_imag` do not match.
174
174
  The number of components is not 2 or 3.
175
- Fraction values are not in range [0.0, 1.0].
175
+ Fraction values are out of range [0.0, 1.0].
176
176
 
177
177
  See Also
178
178
  --------
@@ -213,51 +213,51 @@ def graphical_component_analysis(
213
213
  --------
214
214
  Count the number of phasors between two components:
215
215
 
216
- >>> graphical_component_analysis(
216
+ >>> phasor_component_graphical(
217
217
  ... [0.6, 0.3], [0.35, 0.38], [0.2, 0.9], [0.4, 0.3], fractions=6
218
218
  ... ) # doctest: +NUMBER
219
- (array([0, 0, 1, 0, 1, 0]),)
219
+ (array([0, 0, 1, 0, 1, 0], dtype=uint64),)
220
220
 
221
221
  Count the number of phasors between the combinations of three components:
222
222
 
223
- >>> graphical_component_analysis(
223
+ >>> phasor_component_graphical(
224
224
  ... [0.4, 0.5],
225
225
  ... [0.2, 0.3],
226
226
  ... [0.0, 0.2, 0.9],
227
227
  ... [0.0, 0.4, 0.3],
228
228
  ... fractions=6,
229
229
  ... ) # doctest: +NUMBER +NORMALIZE_WHITESPACE
230
- (array([0, 1, 1, 1, 1, 0]),
231
- array([0, 1, 0, 0, 0, 0]),
232
- array([0, 1, 2, 0, 0, 0]))
230
+ (array([0, 1, 1, 1, 1, 0], dtype=uint64),
231
+ array([0, 1, 0, 0, 0, 0], dtype=uint64),
232
+ array([0, 1, 2, 0, 0, 0], dtype=uint64))
233
233
 
234
234
  """
235
235
  real = numpy.asarray(real)
236
236
  imag = numpy.asarray(imag)
237
- components_real = numpy.asarray(components_real)
238
- components_imag = numpy.asarray(components_imag)
237
+ component_real = numpy.asarray(component_real)
238
+ component_imag = numpy.asarray(component_imag)
239
239
  if (
240
240
  real.shape != imag.shape
241
- or components_real.shape != components_imag.shape
241
+ or component_real.shape != component_imag.shape
242
242
  ):
243
243
  raise ValueError('input array shapes must match')
244
- if components_real.ndim != 1:
244
+ if component_real.ndim != 1:
245
245
  raise ValueError(
246
246
  'component arrays are not one-dimensional: '
247
- f'{components_real.ndim} dimensions found'
247
+ f'{component_real.ndim} dimensions found'
248
248
  )
249
- num_components = len(components_real)
249
+ num_components = len(component_real)
250
250
  if num_components not in {2, 3}:
251
251
  raise ValueError('number of components must be 2 or 3')
252
252
 
253
253
  if fractions is None:
254
254
  longest_distance = 0
255
255
  for i in range(num_components):
256
- a_real = components_real[i]
257
- a_imag = components_imag[i]
256
+ a_real = component_real[i]
257
+ a_imag = component_imag[i]
258
258
  for j in range(i + 1, num_components):
259
- b_real = components_real[j]
260
- b_imag = components_imag[j]
259
+ b_real = component_real[j]
260
+ b_imag = component_imag[j]
261
261
  _, _, length = _segment_direction_and_length(
262
262
  a_real, a_imag, b_real, b_imag
263
263
  )
@@ -274,11 +274,11 @@ def graphical_component_analysis(
274
274
 
275
275
  counts = []
276
276
  for i in range(num_components):
277
- a_real = components_real[i]
278
- a_imag = components_imag[i]
277
+ a_real = component_real[i]
278
+ a_imag = component_imag[i]
279
279
  for j in range(i + 1, num_components):
280
- b_real = components_real[j]
281
- b_imag = components_imag[j]
280
+ b_real = component_real[j]
281
+ b_imag = component_imag[j]
282
282
  ab_real = a_real - b_real
283
283
  ab_imag = a_imag - b_imag
284
284
 
@@ -301,13 +301,184 @@ def graphical_component_analysis(
301
301
  imag,
302
302
  b_real + f * ab_real, # cursor_real
303
303
  b_imag + f * ab_imag, # cursor_imag
304
- components_real[3 - i - j], # c_real
305
- components_imag[3 - i - j], # c_imag
304
+ component_real[3 - i - j], # c_real
305
+ component_imag[3 - i - j], # c_imag
306
306
  radius,
307
307
  )
308
- fraction_counts = numpy.sum(mask)
308
+ fraction_counts = numpy.sum(mask, dtype=numpy.uint64)
309
309
  component_counts.append(fraction_counts)
310
310
 
311
311
  counts.append(numpy.asarray(component_counts))
312
312
 
313
313
  return tuple(counts)
314
+
315
+
316
+ def phasor_component_fit(
317
+ mean: ArrayLike,
318
+ real: ArrayLike,
319
+ imag: ArrayLike,
320
+ component_real: ArrayLike,
321
+ component_imag: ArrayLike,
322
+ /,
323
+ **kwargs: Any,
324
+ ) -> tuple[NDArray[Any], ...]:
325
+ """Return fractions of multiple components from phasor coordinates.
326
+
327
+ Component fractions are obtained from the least-squares solution of a
328
+ linear matrix equation that relates phasor coordinates from one or
329
+ multiple harmonics to component fractions according to [2]_.
330
+
331
+ Up to ``2 * number harmonics + 1`` components can be fit to multi-harmonic
332
+ phasor coordinates, that is up to three components for single harmonic
333
+ phasor coordinates.
334
+
335
+ Parameters
336
+ ----------
337
+ mean : array_like
338
+ Intensity of phasor coordinates.
339
+ real : array_like
340
+ Real component of phasor coordinates.
341
+ Harmonics, if any, must be in the first dimension.
342
+ imag : array_like
343
+ Imaginary component of phasor coordinates.
344
+ Harmonics, if any, must be in the first dimension.
345
+ component_real : array_like
346
+ Real coordinates of components.
347
+ Must be one or two-dimensional with harmonics in the first dimension.
348
+ component_imag : array_like
349
+ Imaginary coordinates of components.
350
+ Must be one or two-dimensional with harmonics in the first dimension.
351
+ **kwargs : optional
352
+ Additional arguments passed to :py:func:`scipy.linalg.lstsq()`.
353
+
354
+ Returns
355
+ -------
356
+ fractions : tuple of ndarray
357
+ Component fractions, one array per component.
358
+ Fractions may not exactly add up to 1.0.
359
+
360
+ Raises
361
+ ------
362
+ ValueError
363
+ The array shapes of `real` and `imag` do not match.
364
+ The array shapes of `component_real` and `component_imag` do not match.
365
+ The number of harmonics in the components does not
366
+ match the ones in the phasor coordinates.
367
+ The system is underdetermined; the component matrix having more
368
+ columns than rows.
369
+
370
+ See Also
371
+ --------
372
+ :ref:`sphx_glr_tutorials_api_phasorpy_components.py`
373
+ :ref:`sphx_glr_tutorials_applications_phasorpy_component_fit.py`
374
+
375
+ Notes
376
+ -----
377
+ For now, calculation of fractions of components from different channels
378
+ or frequencies is not supported. Only one set of components can be
379
+ analyzed and is broadcast to all channels/frequencies.
380
+
381
+ The method builds a linear matrix equation,
382
+ :math:`A\\mathbf{x} = \\mathbf{b}`, where :math:`A` consists of the
383
+ phasor coordinates of individual components, :math:`\\mathbf{x}` are
384
+ the unknown fractions, and :math:`\\mathbf{b}` represents the measured
385
+ phasor coordinates in the mixture. The least-squares solution of this
386
+ linear matrix equation yields the fractions.
387
+
388
+ References
389
+ ----------
390
+ .. [2] Vallmitjana A, Lepanto P, Irigoin F, and Malacrida L.
391
+ `Phasor-based multi-harmonic unmixing for in-vivo hyperspectral
392
+ imaging <https://doi.org/10.1088/2050-6120/ac9ae9>`_.
393
+ *Methods Appl Fluoresc*, 11(1): 014001 (2022)
394
+
395
+ Example
396
+ -------
397
+ >>> phasor_component_fit(
398
+ ... [1, 1, 1], [0.6, 0.5, 0.4], [0.4, 0.3, 0.2], [0.2, 0.9], [0.4, 0.3]
399
+ ... ) # doctest: +NUMBER
400
+ (array([0.4644, 0.5356, 0.6068]), array([0.5559, 0.4441, 0.3322]))
401
+
402
+ """
403
+ from scipy.linalg import lstsq
404
+
405
+ mean = numpy.atleast_1d(mean)
406
+ real = numpy.atleast_1d(real)
407
+ imag = numpy.atleast_1d(imag)
408
+ component_real = numpy.atleast_1d(component_real)
409
+ component_imag = numpy.atleast_1d(component_imag)
410
+
411
+ if real.shape != imag.shape:
412
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
413
+ if mean.shape != real.shape[-mean.ndim :]:
414
+ raise ValueError(f'{mean.shape=} does not match {real.shape=}')
415
+
416
+ if component_real.shape != component_imag.shape:
417
+ raise ValueError(f'{component_real.shape=} != {component_imag.shape=}')
418
+ if numpy.isnan(component_real).any() or numpy.isnan(component_imag).any():
419
+ raise ValueError(
420
+ 'component phasor coordinates must not contain NaN values'
421
+ )
422
+ if numpy.isinf(component_real).any() or numpy.isinf(component_imag).any():
423
+ raise ValueError(
424
+ 'component phasor coordinates must not contain infinite values'
425
+ )
426
+
427
+ if component_real.ndim == 1:
428
+ component_real = component_real.reshape(1, -1)
429
+ component_imag = component_imag.reshape(1, -1)
430
+ elif component_real.ndim > 2:
431
+ raise ValueError(f'{component_real.ndim=} > 2')
432
+
433
+ num_harmonics, num_components = component_real.shape
434
+
435
+ # create component matrix for least squares solving:
436
+ # [real coordinates of components (for each harmonic)] +
437
+ # [imaginary coordinates of components (for each harmonic)] +
438
+ # [ones for intensity constraint]
439
+ component_matrix = numpy.ones((2 * num_harmonics + 1, num_components))
440
+ component_matrix[:num_harmonics] = component_real
441
+ component_matrix[num_harmonics : 2 * num_harmonics] = component_imag
442
+
443
+ if component_matrix.shape[0] < component_matrix.shape[1]:
444
+ raise ValueError(
445
+ 'the system is undetermined '
446
+ f'({num_components=} > {num_harmonics * 2 + 1=})'
447
+ )
448
+
449
+ has_harmonic_axis = mean.ndim + 1 == real.ndim
450
+ if not has_harmonic_axis:
451
+ real = numpy.expand_dims(real, axis=0)
452
+ imag = numpy.expand_dims(imag, axis=0)
453
+ elif real.shape[0] != num_harmonics:
454
+ raise ValueError(f'{real.shape[0]=} != {component_real.shape[0]=}')
455
+
456
+ # TODO: replace Inf with NaN values?
457
+ mean, real, imag = phasor_threshold(mean, real, imag)
458
+
459
+ # replace NaN values with 0.0 for least squares solving
460
+ real = numpy.nan_to_num(real, nan=0.0, copy=False)
461
+ imag = numpy.nan_to_num(imag, nan=0.0, copy=False)
462
+
463
+ # create coordinates matrix for least squares solving:
464
+ # [real coordinates (for each harmonic)] +
465
+ # [imaginary coordinates (for each harmonic)] +
466
+ # [ones for intensity constraint]
467
+ coords = numpy.ones((2 * num_harmonics + 1,) + real.shape[1:])
468
+ coords[:num_harmonics] = real
469
+ coords[num_harmonics : 2 * num_harmonics] = imag
470
+
471
+ fractions = lstsq(
472
+ component_matrix, coords.reshape(coords.shape[0], -1), **kwargs
473
+ )[0]
474
+
475
+ # reshape to match input dimensions
476
+ fractions = fractions.reshape((num_components,) + coords.shape[1:])
477
+
478
+ # TODO: normalize fractions to sum up to 1.0?
479
+ # fractions /= numpy.sum(fractions, axis=0, keepdims=True)
480
+
481
+ # restore NaN values in fractions from mean
482
+ _blend_and(mean, fractions, out=fractions)
483
+
484
+ return tuple(fractions)
phasorpy/conftest.py CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ __all__: list[str] = []
6
+
5
7
  import math
6
8
  from typing import TYPE_CHECKING
7
9
 
phasorpy/cursors.py CHANGED
@@ -291,15 +291,15 @@ def mask_from_polar_cursor(
291
291
  imag : array_like
292
292
  Imaginary component of phasor coordinates.
293
293
  phase_min : array_like, shape (n,)
294
- Minimum of angular range of cursors in radians.
295
- Values should be between -pi and pi.
294
+ Lower bound of angular range of cursors in radians.
295
+ Values should be in range [-pi, pi].
296
296
  phase_max : array_like, shape (n,)
297
- Maximum of angular range of cursors in radians.
298
- Values should be between -pi and pi.
297
+ Upper bound of angular range of cursors in radians.
298
+ Values should be in range [-pi, pi].
299
299
  modulation_min : array_like, shape (n,)
300
- Minimum of radial range of cursors.
300
+ Lower bound of radial range of cursors.
301
301
  modulation_max : array_like, shape (n,)
302
- Maximum of radial range of cursors.
302
+ Upper bound of radial range of cursors.
303
303
 
304
304
  Returns
305
305
  -------
@@ -362,7 +362,7 @@ def mask_from_polar_cursor(
362
362
  f'{phase_min.ndim=}, {phase_max.ndim=}, '
363
363
  f'{modulation_min.ndim=}, or {modulation_max.ndim=} > 1'
364
364
  )
365
- # TODO: check if angles are between -pi and pi
365
+ # TODO: check if angles are in range [-pi and pi]
366
366
 
367
367
  moveaxis = False
368
368
  if real.ndim > 0 and (
@@ -406,7 +406,7 @@ def pseudo_color(
406
406
  Maximum value to normalize `intensity`.
407
407
  If None, the maximum value of `intensity` is used.
408
408
  colors : array_like, optional, shape (N, 3)
409
- Colors assigned to each cursor.
409
+ RGB colors assigned to each cursor.
410
410
  The last dimension contains the normalized RGB floating point values.
411
411
  The default is :py:data:`phasorpy.color.CATEGORICAL`.
412
412
 
@@ -463,7 +463,7 @@ def pseudo_color(
463
463
  shape = numpy.asarray(masks[0]).shape
464
464
 
465
465
  if intensity is not None:
466
- # normalize intensity to range 0..1
466
+ # normalize intensity to range [0, 1]
467
467
  intensity = numpy.array(
468
468
  intensity, dtype=numpy.float32, ndmin=1, copy=True
469
469
  )