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