phasorpy 0.7__cp314-cp314-macosx_11_0_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/conftest.py ADDED
@@ -0,0 +1,38 @@
1
+ """Pytest configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []
6
+
7
+ import math
8
+ from typing import TYPE_CHECKING
9
+
10
+ import numpy
11
+ import pytest
12
+
13
+ from .datasets import fetch
14
+
15
+ if TYPE_CHECKING:
16
+ from ._typing import Any
17
+
18
+ # numpy 2.0 changed the scalar type representation,
19
+ # causing many doctests to fail.
20
+ numpy.set_printoptions(legacy='1.21')
21
+
22
+
23
+ @pytest.fixture(autouse=True)
24
+ def add_doctest_namespace(doctest_namespace: dict[str, Any]) -> None:
25
+ """Add common modules and functions to doctest namespace."""
26
+ doctest_namespace['fetch'] = fetch
27
+ doctest_namespace['math'] = math
28
+ doctest_namespace['numpy'] = numpy
29
+
30
+
31
+ @pytest.fixture(autouse=True)
32
+ def set_printoptions() -> None:
33
+ """Adjust numpy array print options for use with `# doctest: +NUMBER`."""
34
+ numpy.set_printoptions(
35
+ # precision=3,
36
+ threshold=5,
37
+ formatter={'float': lambda x: f'{x:.4g}'}, # remove whitespace
38
+ )
phasorpy/cursor.py ADDED
@@ -0,0 +1,500 @@
1
+ """Select regions of interest (cursors) from phasor coordinates.
2
+
3
+ The ``phasorpy.cursor`` module provides functions to:
4
+
5
+ - create masks for regions of interest in the phasor space:
6
+
7
+ - :py:func:`mask_from_circular_cursor`
8
+ - :py:func:`mask_from_elliptic_cursor`
9
+ - :py:func:`mask_from_polar_cursor`
10
+
11
+ - create pseudo-color image from average signal and cursor masks:
12
+
13
+ - :py:func:`pseudo_color`
14
+
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ __all__ = [
20
+ 'mask_from_circular_cursor',
21
+ 'mask_from_elliptic_cursor',
22
+ 'mask_from_polar_cursor',
23
+ 'pseudo_color',
24
+ ]
25
+
26
+ from typing import TYPE_CHECKING
27
+
28
+ if TYPE_CHECKING:
29
+ from ._typing import ArrayLike, Literal, NDArray
30
+
31
+ import numpy
32
+
33
+ from phasorpy.color import CATEGORICAL
34
+
35
+ from ._phasorpy import (
36
+ _blend_normal,
37
+ _blend_overlay,
38
+ _is_inside_circle,
39
+ _is_inside_ellipse_,
40
+ _is_inside_polar_rectangle,
41
+ )
42
+
43
+
44
+ def mask_from_circular_cursor(
45
+ real: ArrayLike,
46
+ imag: ArrayLike,
47
+ center_real: ArrayLike,
48
+ center_imag: ArrayLike,
49
+ /,
50
+ *,
51
+ radius: ArrayLike = 0.05,
52
+ ) -> NDArray[numpy.bool_]:
53
+ """Return masks for circular cursors of phasor coordinates.
54
+
55
+ Parameters
56
+ ----------
57
+ real : array_like
58
+ Real component of phasor coordinates.
59
+ imag : array_like
60
+ Imaginary component of phasor coordinates.
61
+ center_real : array_like, shape (n,)
62
+ Real coordinates of circle centers.
63
+ center_imag : array_like, shape (n,)
64
+ Imaginary coordinates of circle centers.
65
+ radius : array_like, optional, shape (n,)
66
+ Radii of circles.
67
+
68
+ Returns
69
+ -------
70
+ masks : ndarray
71
+ Boolean array of shape `(n, *real.shape)`.
72
+ The first dimension is omitted if `center_*` and `radius` are scalars.
73
+ Values are True if phasor coordinates are inside circular cursor,
74
+ else False.
75
+
76
+ Raises
77
+ ------
78
+ ValueError
79
+ The array shapes of `real` and `imag` do not match.
80
+ The array shapes of `center_real`, `center_imag`, or `radius` have
81
+ more than one dimension.
82
+
83
+ See Also
84
+ --------
85
+ :ref:`sphx_glr_tutorials_api_phasorpy_cursor.py`
86
+
87
+ Examples
88
+ --------
89
+ Create mask for a single circular cursor:
90
+
91
+ >>> mask_from_circular_cursor([0.2, 0.5], [0.4, 0.5], 0.2, 0.4, radius=0.1)
92
+ array([ True, False])
93
+
94
+ Create masks for two circular cursors with different radius:
95
+
96
+ >>> mask_from_circular_cursor(
97
+ ... [0.2, 0.5], [0.4, 0.5], [0.2, 0.5], [0.4, 0.5], radius=[0.1, 0.05]
98
+ ... )
99
+ array([[ True, False],
100
+ [False, True]])
101
+
102
+ """
103
+ real = numpy.asarray(real)
104
+ imag = numpy.asarray(imag)
105
+ center_real = numpy.asarray(center_real)
106
+ center_imag = numpy.asarray(center_imag)
107
+ radius = numpy.asarray(radius)
108
+
109
+ if real.shape != imag.shape:
110
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
111
+ if center_real.ndim > 1 or center_imag.ndim > 1 or radius.ndim > 1:
112
+ raise ValueError(
113
+ f'{center_real.ndim=}, {center_imag.ndim=}, or {radius.ndim=} > 1'
114
+ )
115
+
116
+ moveaxis = False
117
+ if real.ndim > 0 and (
118
+ center_real.ndim > 0 or center_imag.ndim > 0 or radius.ndim > 0
119
+ ):
120
+ moveaxis = True
121
+ real = numpy.expand_dims(real, axis=-1)
122
+ imag = numpy.expand_dims(imag, axis=-1)
123
+
124
+ mask = _is_inside_circle(real, imag, center_real, center_imag, radius)
125
+ if moveaxis:
126
+ mask = numpy.moveaxis(mask, -1, 0)
127
+ return mask.astype(numpy.bool_) # type: ignore[no-any-return]
128
+
129
+
130
+ def mask_from_elliptic_cursor(
131
+ real: ArrayLike,
132
+ imag: ArrayLike,
133
+ center_real: ArrayLike,
134
+ center_imag: ArrayLike,
135
+ /,
136
+ *,
137
+ radius: ArrayLike = 0.05,
138
+ radius_minor: ArrayLike | None = None,
139
+ angle: ArrayLike | Literal['phase', 'semicircle'] | str | None = None,
140
+ ) -> NDArray[numpy.bool_]:
141
+ """Return masks for elliptic cursors of phasor coordinates.
142
+
143
+ Parameters
144
+ ----------
145
+ real : array_like
146
+ Real component of phasor coordinates.
147
+ imag : array_like
148
+ Imaginary component of phasor coordinates.
149
+ center_real : array_like, shape (n,)
150
+ Real coordinates of ellipses centers.
151
+ center_imag : array_like, shape (n,)
152
+ Imaginary coordinates of ellipses centers.
153
+ radius : array_like, optional, shape (n,)
154
+ Radii of ellipses along semi-major axis.
155
+ radius_minor : array_like, optional, shape (n,)
156
+ Radii of ellipses along semi-minor axis.
157
+ By default, the ellipses are circular.
158
+ angle : array_like or {'phase', 'semicircle'}, optional
159
+ Rotation angle of semi-major axis of elliptic cursors in radians.
160
+ If None or 'phase', align the minor axes of the ellipses with
161
+ the closest tangent on the unit circle.
162
+ If 'semicircle', align the ellipses with the universal semicircle.
163
+
164
+ Returns
165
+ -------
166
+ masks : ndarray
167
+ Boolean array of shape `(n, *real.shape)`.
168
+ The first dimension is omitted if `center_real`, `center_imag`,
169
+ `radius`, `radius_minor`, and `angle` are scalars.
170
+ Values are True if phasor coordinates are inside elliptic cursor,
171
+ else False.
172
+
173
+ Raises
174
+ ------
175
+ ValueError
176
+ The array shapes of `real` and `imag` do not match.
177
+ The array shapes of `center_real`, `center_imag`, `radius`,
178
+ `radius_minor`, or `angle` have more than one dimension.
179
+
180
+ See Also
181
+ --------
182
+ :ref:`sphx_glr_tutorials_api_phasorpy_cursor.py`
183
+
184
+ Examples
185
+ --------
186
+ Create mask for a single elliptic cursor:
187
+
188
+ >>> mask_from_elliptic_cursor([0.2, 0.5], [0.4, 0.5], 0.2, 0.4, radius=0.1)
189
+ array([ True, False])
190
+
191
+ Create masks for two elliptic cursors with different radii:
192
+
193
+ >>> mask_from_elliptic_cursor(
194
+ ... [0.2, 0.5],
195
+ ... [0.4, 0.5],
196
+ ... [0.2, 0.5],
197
+ ... [0.4, 0.5],
198
+ ... radius=[0.1, 0.05],
199
+ ... radius_minor=[0.15, 0.1],
200
+ ... angle=[math.pi, math.pi / 2],
201
+ ... )
202
+ array([[ True, False],
203
+ [False, True]])
204
+
205
+ """
206
+ real = numpy.asarray(real)
207
+ imag = numpy.asarray(imag)
208
+ center_real = numpy.asarray(center_real)
209
+ center_imag = numpy.asarray(center_imag)
210
+ radius_a = numpy.asarray(radius)
211
+ if radius_minor is None:
212
+ radius_b = radius_a # circular by default
213
+ angle = 0.0
214
+ else:
215
+ radius_b = numpy.asarray(radius_minor)
216
+
217
+ if angle is None:
218
+ angle = numpy.arctan2(center_imag, center_real)
219
+ elif isinstance(angle, str):
220
+ # TODO: vectorize str type
221
+ if angle == 'phase':
222
+ angle = numpy.arctan2(center_imag, center_real)
223
+ elif angle == 'semicircle':
224
+ angle = numpy.arctan2(center_imag, center_real - 0.5)
225
+ else:
226
+ raise ValueError(f'invalid {angle=}')
227
+
228
+ angle_sin = numpy.sin(angle)
229
+ angle_cos = numpy.cos(angle)
230
+
231
+ if real.shape != imag.shape:
232
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
233
+ if (
234
+ center_real.ndim > 1
235
+ or center_imag.ndim > 1
236
+ or radius_a.ndim > 1
237
+ or radius_b.ndim > 1
238
+ or angle_sin.ndim > 1
239
+ ):
240
+ raise ValueError(
241
+ f'{center_real.ndim=}, {center_imag.ndim=}, '
242
+ f'radius.ndim={radius_a.ndim}, '
243
+ f'radius_minor.ndim={radius_b.ndim}, or '
244
+ f'angle.ndim={angle_sin.ndim}, > 1'
245
+ )
246
+
247
+ moveaxis = False
248
+ if real.ndim > 0 and (
249
+ center_real.ndim > 0
250
+ or center_imag.ndim > 0
251
+ or radius_a.ndim > 0
252
+ or radius_b.ndim > 0
253
+ or angle_sin.ndim > 0
254
+ ):
255
+ moveaxis = True
256
+ real = numpy.expand_dims(real, axis=-1)
257
+ imag = numpy.expand_dims(imag, axis=-1)
258
+
259
+ mask = _is_inside_ellipse_(
260
+ real,
261
+ imag,
262
+ center_real,
263
+ center_imag,
264
+ radius_a,
265
+ radius_b,
266
+ angle_sin,
267
+ angle_cos,
268
+ )
269
+ if moveaxis:
270
+ mask = numpy.moveaxis(mask, -1, 0)
271
+ return mask.astype(numpy.bool_) # type: ignore[no-any-return]
272
+
273
+
274
+ def mask_from_polar_cursor(
275
+ real: ArrayLike,
276
+ imag: ArrayLike,
277
+ phase_min: ArrayLike,
278
+ phase_max: ArrayLike,
279
+ modulation_min: ArrayLike,
280
+ modulation_max: ArrayLike,
281
+ /,
282
+ ) -> NDArray[numpy.bool_]:
283
+ """Return mask for polar cursor of polar coordinates.
284
+
285
+ Parameters
286
+ ----------
287
+ real : array_like
288
+ Real component of phasor coordinates.
289
+ imag : array_like
290
+ Imaginary component of phasor coordinates.
291
+ phase_min : array_like, shape (n,)
292
+ Lower bound of angular range of cursors in radians.
293
+ Values should be in range [-pi, pi].
294
+ phase_max : array_like, shape (n,)
295
+ Upper bound of angular range of cursors in radians.
296
+ Values should be in range [-pi, pi].
297
+ modulation_min : array_like, shape (n,)
298
+ Lower bound of radial range of cursors.
299
+ modulation_max : array_like, shape (n,)
300
+ Upper bound of radial range of cursors.
301
+
302
+ Returns
303
+ -------
304
+ masks : ndarray
305
+ Boolean array of shape `(n, *real.shape)`.
306
+ The first dimension is omitted if `phase_*` and `modulation_*`
307
+ are scalars.
308
+ Values are True if phasor coordinates are inside polar range cursor,
309
+ else False.
310
+
311
+ Raises
312
+ ------
313
+ ValueError
314
+ The array shapes of `phase` and `modulation`, or `phase_range` and
315
+ `modulation_range` do not match.
316
+ The array shapes of `phase_*` or `modulation_*` have more than
317
+ one dimension.
318
+
319
+ See Also
320
+ --------
321
+ :ref:`sphx_glr_tutorials_api_phasorpy_cursor.py`
322
+
323
+ Examples
324
+ --------
325
+ Create mask from a single polar cursor:
326
+
327
+ >>> mask_from_polar_cursor([0.2, 0.5], [0.4, 0.5], 1.1, 1.2, 0.4, 0.5)
328
+ array([ True, False])
329
+
330
+ Create masks for two polar cursors with different ranges:
331
+
332
+ >>> mask_from_polar_cursor(
333
+ ... [0.2, 0.5],
334
+ ... [0.4, 0.5],
335
+ ... [1.1, 0.7],
336
+ ... [1.2, 0.8],
337
+ ... [0.4, 0.7],
338
+ ... [0.5, 0.8],
339
+ ... )
340
+ array([[ True, False],
341
+ [False, True]])
342
+
343
+ """
344
+ real = numpy.asarray(real)
345
+ imag = numpy.asarray(imag)
346
+ phase_min = numpy.asarray(phase_min)
347
+ phase_max = numpy.asarray(phase_max)
348
+ modulation_min = numpy.asarray(modulation_min)
349
+ modulation_max = numpy.asarray(modulation_max)
350
+
351
+ if real.shape != imag.shape:
352
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
353
+ if (
354
+ phase_min.ndim > 1
355
+ or phase_max.ndim > 1
356
+ or modulation_min.ndim > 1
357
+ or modulation_max.ndim > 1
358
+ ):
359
+ raise ValueError(
360
+ f'{phase_min.ndim=}, {phase_max.ndim=}, '
361
+ f'{modulation_min.ndim=}, or {modulation_max.ndim=} > 1'
362
+ )
363
+ # TODO: check if angles are in range [-pi and pi]
364
+
365
+ moveaxis = False
366
+ if real.ndim > 0 and (
367
+ phase_min.ndim > 0
368
+ or phase_max.ndim > 0
369
+ or modulation_min.ndim > 0
370
+ or modulation_max.ndim > 0
371
+ ):
372
+ moveaxis = True
373
+ real = numpy.expand_dims(real, axis=-1)
374
+ imag = numpy.expand_dims(imag, axis=-1)
375
+
376
+ mask = _is_inside_polar_rectangle(
377
+ real, imag, phase_min, phase_max, modulation_min, modulation_max
378
+ )
379
+ if moveaxis:
380
+ mask = numpy.moveaxis(mask, -1, 0)
381
+ return mask.astype(numpy.bool_) # type: ignore[no-any-return]
382
+
383
+
384
+ def pseudo_color(
385
+ *masks: ArrayLike,
386
+ intensity: ArrayLike | None = None,
387
+ colors: ArrayLike | None = None,
388
+ vmin: float | None = 0.0,
389
+ vmax: float | None = None,
390
+ ) -> NDArray[numpy.float32]:
391
+ """Return pseudo-colored image from cursor masks.
392
+
393
+ Parameters
394
+ ----------
395
+ *masks : array_like
396
+ Boolean mask for each cursor.
397
+ intensity : array_like, optional
398
+ Intensity used as base layer to blend cursor colors in "overlay" mode.
399
+ If None, cursor masks are blended using "screen" mode.
400
+ vmin : float, optional
401
+ Minimum value to normalize `intensity`.
402
+ If None, the minimum value of `intensity` is used.
403
+ vmax : float, optional
404
+ Maximum value to normalize `intensity`.
405
+ If None, the maximum value of `intensity` is used.
406
+ colors : array_like, optional, shape (N, 3)
407
+ RGB colors assigned to each cursor.
408
+ The last dimension contains the normalized RGB floating point values.
409
+ The default is :py:data:`phasorpy.color.CATEGORICAL`.
410
+
411
+ Returns
412
+ -------
413
+ ndarray
414
+ Pseudo-colored image of shape ``(*masks[0].shape, 3)``.
415
+
416
+ Raises
417
+ ------
418
+ ValueError
419
+ `colors` is not a (n, 3) shaped floating point array.
420
+ The shapes of `masks` or `mean` cannot broadcast.
421
+
422
+ See Also
423
+ --------
424
+ :ref:`sphx_glr_tutorials_api_phasorpy_cursor.py`
425
+
426
+ Examples
427
+ --------
428
+ Create pseudo-color image from single mask:
429
+
430
+ >>> pseudo_color([True, False, True]) # doctest: +NUMBER
431
+ array([[0.8254, 0.09524, 0.127],
432
+ [0, 0, 0],
433
+ [0.8254, 0.09524, 0.127]]...)
434
+
435
+ Create pseudo-color image from two masks and intensity image:
436
+
437
+ >>> pseudo_color(
438
+ ... [True, False], [False, True], intensity=[0.4, 0.6], vmax=1.0
439
+ ... ) # doctest: +NUMBER
440
+ array([[0.6603, 0.07619, 0.1016],
441
+ [0.2762, 0.5302, 1]]...)
442
+
443
+ """
444
+ if len(masks) == 0:
445
+ raise TypeError(
446
+ "pseudo_color() missing 1 required positional argument: 'masks'"
447
+ )
448
+
449
+ if colors is None:
450
+ colors = CATEGORICAL
451
+ else:
452
+ colors = numpy.asarray(colors)
453
+ if colors.ndim != 2:
454
+ raise ValueError(f'{colors.ndim=} != 2')
455
+ if colors.shape[-1] != 3:
456
+ raise ValueError(f'{colors.shape[-1]=} != 3')
457
+ if colors.dtype.kind != 'f':
458
+ raise ValueError('colors is not a floating point array')
459
+ # TODO: add support for matplotlib colors
460
+
461
+ shape = numpy.asarray(masks[0]).shape
462
+
463
+ if intensity is not None:
464
+ # normalize intensity to range [0, 1]
465
+ intensity = numpy.array(
466
+ intensity, dtype=numpy.float32, ndmin=1, copy=True
467
+ )
468
+ if intensity.size > 1:
469
+ if vmin is None:
470
+ vmin = numpy.nanmin(intensity)
471
+ if vmax is None:
472
+ vmax = numpy.nanmax(intensity)
473
+ if vmin != 0.0:
474
+ intensity -= vmin
475
+ scale = vmax - vmin
476
+ if scale != 0.0 and scale != 1.0:
477
+ intensity /= scale
478
+ numpy.clip(intensity, 0.0, 1.0, out=intensity)
479
+ if intensity.shape == shape:
480
+ intensity = intensity[..., numpy.newaxis]
481
+ pseudocolor = numpy.full((*shape, 3), intensity, dtype=numpy.float32)
482
+ else:
483
+ pseudocolor = numpy.zeros((*shape, 3), dtype=numpy.float32)
484
+
485
+ # TODO: support intensity or RGB input in addition to masks
486
+ blend = numpy.empty_like(pseudocolor)
487
+ for i, mask_ in enumerate(masks):
488
+ mask = numpy.asarray(mask_)
489
+ if mask.shape != shape:
490
+ raise ValueError(f'masks[{i}].shape={mask.shape} != {shape}')
491
+ blend.fill(numpy.nan)
492
+ blend[mask] = colors[i]
493
+ if intensity is None:
494
+ # TODO: replace by _blend_screen?
495
+ _blend_normal(pseudocolor, blend, out=pseudocolor)
496
+ else:
497
+ _blend_overlay(pseudocolor, blend, out=pseudocolor)
498
+
499
+ pseudocolor.clip(0.0, 1.0, out=pseudocolor)
500
+ return pseudocolor