phasorpy 0.7__cp314-cp314t-win_amd64.whl → 0.8__cp314-cp314t-win_amd64.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/filter.py ADDED
@@ -0,0 +1,966 @@
1
+ """Filter signals and phasor coordinates.
2
+
3
+ The ``phasorpy.filter`` module provides functions to filter
4
+
5
+ - phasor coordinates:
6
+
7
+ - :py:func:`phasor_filter_gaussian` (not implemented yet)
8
+ - :py:func:`phasor_filter_median`
9
+ - :py:func:`phasor_filter_pawflim`
10
+ - :py:func:`phasor_threshold`
11
+
12
+ - signals:
13
+
14
+ - :py:func:`signal_filter_ncpca`
15
+ (noise-corrected principal component analysis)
16
+ - :py:func:`signal_filter_svd` (spectral vector denoise)
17
+ - :py:func:`signal_filter_median` (not implemented yet)
18
+
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ __all__ = [
24
+ # 'signal_filter_gaussian',
25
+ 'phasor_filter_median',
26
+ 'phasor_filter_pawflim',
27
+ 'phasor_threshold',
28
+ # 'signal_filter_median',
29
+ 'signal_filter_ncpca',
30
+ 'signal_filter_svd',
31
+ ]
32
+
33
+ import math
34
+ from collections.abc import Sequence
35
+ from typing import TYPE_CHECKING
36
+
37
+ if TYPE_CHECKING:
38
+ from ._typing import Any, NDArray, ArrayLike, DTypeLike, Literal
39
+
40
+ import numpy
41
+
42
+ from ._phasorpy import (
43
+ _median_filter_2d,
44
+ _phasor_from_signal_vector,
45
+ _phasor_threshold_closed,
46
+ _phasor_threshold_mean_closed,
47
+ _phasor_threshold_mean_open,
48
+ _phasor_threshold_nan,
49
+ _phasor_threshold_open,
50
+ _signal_denoise_vector,
51
+ )
52
+ from ._utils import parse_harmonic, parse_skip_axis
53
+ from .utils import number_threads
54
+
55
+
56
+ def phasor_filter_median(
57
+ mean: ArrayLike,
58
+ real: ArrayLike,
59
+ imag: ArrayLike,
60
+ /,
61
+ *,
62
+ repeat: int = 1,
63
+ size: int = 3,
64
+ skip_axis: int | Sequence[int] | None = None,
65
+ use_scipy: bool = False,
66
+ num_threads: int | None = None,
67
+ **kwargs: Any,
68
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
69
+ """Return median-filtered phasor coordinates.
70
+
71
+ By default, apply a NaN-aware median filter independently to the real
72
+ and imaginary components of phasor coordinates once with a kernel size of 3
73
+ multiplied by the number of dimensions of the input arrays. Return the
74
+ intensity unchanged.
75
+
76
+ Parameters
77
+ ----------
78
+ mean : array_like
79
+ Intensity of phasor coordinates.
80
+ real : array_like
81
+ Real component of phasor coordinates to be filtered.
82
+ imag : array_like
83
+ Imaginary component of phasor coordinates to be filtered.
84
+ repeat : int, optional
85
+ Number of times to apply median filter. The default is 1.
86
+ size : int, optional
87
+ Size of median filter kernel. The default is 3.
88
+ skip_axis : int or sequence of int, optional
89
+ Axes in `mean` to exclude from filter.
90
+ By default, all axes except harmonics are included.
91
+ use_scipy : bool, optional
92
+ Use :py:func:`scipy.ndimage.median_filter`.
93
+ This function has undefined behavior if the input arrays contain
94
+ NaN values but is faster when filtering more than 2 dimensions.
95
+ See `issue #87 <https://github.com/phasorpy/phasorpy/issues/87>`_.
96
+ num_threads : int, optional
97
+ Number of OpenMP threads to use for parallelization.
98
+ Applies to filtering in two dimensions when not using scipy.
99
+ By default, multi-threading is disabled.
100
+ If zero, up to half of logical CPUs are used.
101
+ OpenMP may not be available on all platforms.
102
+ **kwargs
103
+ Optional arguments passed to :py:func:`scipy.ndimage.median_filter`.
104
+
105
+ Returns
106
+ -------
107
+ mean : ndarray
108
+ Unchanged intensity of phasor coordinates.
109
+ real : ndarray
110
+ Filtered real component of phasor coordinates.
111
+ imag : ndarray
112
+ Filtered imaginary component of phasor coordinates.
113
+
114
+ Raises
115
+ ------
116
+ ValueError
117
+ If `repeat` is less than 0.
118
+ If `size` is less than 1.
119
+ The array shapes of `mean`, `real`, and `imag` do not match.
120
+
121
+ Examples
122
+ --------
123
+ Apply three times a median filter with a kernel size of three:
124
+
125
+ >>> mean, real, imag = phasor_filter_median(
126
+ ... [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
127
+ ... [[0.0, 0.0, 0.0], [0.5, 0.5, 0.5], [0.2, 0.2, 0.2]],
128
+ ... [[0.3, 0.3, 0.3], [0.6, math.nan, 0.6], [0.4, 0.4, 0.4]],
129
+ ... size=3,
130
+ ... repeat=3,
131
+ ... )
132
+ >>> mean
133
+ array([[1, 2, 3],
134
+ [4, 5, 6],
135
+ [7, 8, 9]])
136
+ >>> real
137
+ array([[0, 0, 0],
138
+ [0.2, 0.2, 0.2],
139
+ [0.2, 0.2, 0.2]])
140
+ >>> imag
141
+ array([[0.3, 0.3, 0.3],
142
+ [0.4, nan, 0.4],
143
+ [0.4, 0.4, 0.4]])
144
+
145
+ """
146
+ if repeat < 0:
147
+ raise ValueError(f'{repeat=} < 0')
148
+ if size < 1:
149
+ raise ValueError(f'{size=} < 1')
150
+ if size == 1:
151
+ # no need to filter
152
+ repeat = 0
153
+
154
+ mean = numpy.asarray(mean)
155
+ if use_scipy or repeat == 0: # or using nD numpy filter
156
+ real = numpy.asarray(real)
157
+ elif isinstance(real, numpy.ndarray) and real.dtype == numpy.float32:
158
+ real = real.copy()
159
+ else:
160
+ real = numpy.asarray(real, dtype=numpy.float64, copy=True)
161
+ if use_scipy or repeat == 0: # or using nD numpy filter
162
+ imag = numpy.asarray(imag)
163
+ elif isinstance(imag, numpy.ndarray) and imag.dtype == numpy.float32:
164
+ imag = imag.copy()
165
+ else:
166
+ imag = numpy.asarray(imag, dtype=numpy.float64, copy=True)
167
+
168
+ if mean.shape != real.shape[-mean.ndim if mean.ndim else 1 :]:
169
+ raise ValueError(f'{mean.shape=} != {real.shape=}')
170
+ if real.shape != imag.shape:
171
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
172
+
173
+ prepend_axis = mean.ndim + 1 == real.ndim
174
+ _, axes = parse_skip_axis(skip_axis, mean.ndim, prepend_axis)
175
+
176
+ # in case mean is also filtered
177
+ # if prepend_axis:
178
+ # mean = numpy.expand_dims(mean, axis=0)
179
+ # ...
180
+ # if prepend_axis:
181
+ # mean = numpy.asarray(mean[0])
182
+
183
+ if repeat == 0:
184
+ # no need to call filter
185
+ return mean, real, imag
186
+
187
+ if use_scipy:
188
+ # use scipy NaN-unaware fallback
189
+ from scipy.ndimage import median_filter
190
+
191
+ kwargs.pop('axes', None)
192
+
193
+ for _ in range(repeat):
194
+ real = median_filter(real, size=size, axes=axes, **kwargs)
195
+ imag = median_filter(imag, size=size, axes=axes, **kwargs)
196
+
197
+ return mean, numpy.asarray(real), numpy.asarray(imag)
198
+
199
+ if len(axes) != 2:
200
+ # n-dimensional median filter using numpy
201
+ from numpy.lib.stride_tricks import sliding_window_view
202
+
203
+ kernel_shape = tuple(
204
+ size if i in axes else 1 for i in range(real.ndim)
205
+ )
206
+ pad_width = [
207
+ (s // 2, s // 2) if s > 1 else (0, 0) for s in kernel_shape
208
+ ]
209
+ axis = tuple(range(-real.ndim, 0))
210
+
211
+ nan_mask = numpy.isnan(real)
212
+ for _ in range(repeat):
213
+ real = numpy.pad(real, pad_width, mode='edge')
214
+ real = sliding_window_view(real, kernel_shape)
215
+ real = numpy.nanmedian(real, axis=axis)
216
+ real = numpy.where(nan_mask, numpy.nan, real)
217
+
218
+ nan_mask = numpy.isnan(imag)
219
+ for _ in range(repeat):
220
+ imag = numpy.pad(imag, pad_width, mode='edge')
221
+ imag = sliding_window_view(imag, kernel_shape)
222
+ imag = numpy.nanmedian(imag, axis=axis)
223
+ imag = numpy.where(nan_mask, numpy.nan, imag)
224
+
225
+ return mean, real, imag
226
+
227
+ # 2-dimensional median filter using optimized Cython implementation
228
+ num_threads = number_threads(num_threads)
229
+
230
+ buffer = numpy.empty(
231
+ tuple(real.shape[axis] for axis in axes), dtype=real.dtype
232
+ )
233
+
234
+ for index in numpy.ndindex(
235
+ *[real.shape[ax] for ax in range(real.ndim) if ax not in axes]
236
+ ):
237
+ index_list: list[int | slice] = list(index)
238
+ for ax in axes:
239
+ index_list = index_list[:ax] + [slice(None)] + index_list[ax:]
240
+ full_index = tuple(index_list)
241
+
242
+ _median_filter_2d(real[full_index], buffer, size, repeat, num_threads)
243
+ _median_filter_2d(imag[full_index], buffer, size, repeat, num_threads)
244
+
245
+ return mean, real, imag
246
+
247
+
248
+ def phasor_filter_pawflim(
249
+ mean: ArrayLike,
250
+ real: ArrayLike,
251
+ imag: ArrayLike,
252
+ /,
253
+ *,
254
+ sigma: float = 2.0,
255
+ levels: int = 1,
256
+ harmonic: Sequence[int] | None = None,
257
+ skip_axis: int | Sequence[int] | None = None,
258
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
259
+ """Return pawFLIM wavelet-filtered phasor coordinates.
260
+
261
+ This function must only be used with calibrated, unprocessed phasor
262
+ coordinates obtained from FLIM data. The coordinates must not be filtered,
263
+ thresholded, or otherwise pre-processed.
264
+
265
+ The pawFLIM wavelet filter is described in [1]_.
266
+
267
+ Parameters
268
+ ----------
269
+ mean : array_like
270
+ Intensity of phasor coordinates.
271
+ real : array_like
272
+ Real component of phasor coordinates to be filtered.
273
+ Must have at least two harmonics in the first axis.
274
+ imag : array_like
275
+ Imaginary component of phasor coordinates to be filtered.
276
+ Must have at least two harmonics in the first axis.
277
+ sigma : float, optional
278
+ Significance level to test difference between two phasors.
279
+ Given in terms of the equivalent 1D standard deviations.
280
+ sigma=2 corresponds to ~95% (or 5%) significance.
281
+ levels : int, optional
282
+ Number of levels for wavelet decomposition.
283
+ Controls the maximum averaging area, which has a length of
284
+ :math:`2^level`.
285
+ harmonic : sequence of int or None, optional
286
+ Harmonics included in first axis of `real` and `imag`.
287
+ If None (default), the first axis of `real` and `imag` contains lower
288
+ harmonics starting at and increasing by one.
289
+ All harmonics must have a corresponding half or double harmonic.
290
+ skip_axis : int or sequence of int, optional
291
+ Axes in `mean` to exclude from filter.
292
+ By default, all axes except harmonics are included.
293
+
294
+ Returns
295
+ -------
296
+ mean : ndarray
297
+ Unchanged intensity of phasor coordinates.
298
+ real : ndarray
299
+ Filtered real component of phasor coordinates.
300
+ imag : ndarray
301
+ Filtered imaginary component of phasor coordinates.
302
+
303
+ Raises
304
+ ------
305
+ ValueError
306
+ If `level` is less than 0.
307
+ The array shapes of `mean`, `real`, and `imag` do not match.
308
+ If `real` and `imag` have no harmonic axis.
309
+ Number of harmonics in `harmonic` is less than 2 or does not match
310
+ the first axis of `real` and `imag`.
311
+ Not all harmonics in `harmonic` have a corresponding half
312
+ or double harmonic.
313
+
314
+ References
315
+ ----------
316
+ .. [1] Silberberg M, and Grecco H. `pawFLIM: reducing bias and
317
+ uncertainty to enable lower photon count in FLIM experiments
318
+ <https://doi.org/10.1088/2050-6120/aa72ab>`_.
319
+ *Methods Appl Fluoresc*, 5(2): 024016 (2017)
320
+
321
+ Examples
322
+ --------
323
+ Apply a pawFLIM wavelet filter with four significance levels (sigma)
324
+ and three decomposition levels:
325
+
326
+ >>> mean, real, imag = phasor_filter_pawflim(
327
+ ... [[1, 1], [1, 1]],
328
+ ... [[[0.5, 0.8], [0.5, 0.8]], [[0.2, 0.4], [0.2, 0.4]]],
329
+ ... [[[0.5, 0.4], [0.5, 0.4]], [[0.4, 0.5], [0.4, 0.5]]],
330
+ ... sigma=4,
331
+ ... levels=3,
332
+ ... harmonic=[1, 2],
333
+ ... )
334
+ >>> mean
335
+ array([[1, 1],
336
+ [1, 1]])
337
+ >>> real
338
+ array([[[0.65, 0.65],
339
+ [0.65, 0.65]],
340
+ [[0.3, 0.3],
341
+ [0.3, 0.3]]])
342
+ >>> imag
343
+ array([[[0.45, 0.45],
344
+ [0.45, 0.45]],
345
+ [[0.45, 0.45],
346
+ [0.45, 0.45]]])
347
+
348
+ """
349
+ from pawflim import pawflim # type: ignore[import-untyped]
350
+
351
+ if levels < 0:
352
+ raise ValueError(f'{levels=} < 0')
353
+ if levels == 0:
354
+ return numpy.asarray(mean), numpy.asarray(real), numpy.asarray(imag)
355
+
356
+ mean = numpy.asarray(mean, dtype=numpy.float64, copy=True)
357
+ real = numpy.asarray(real, dtype=numpy.float64, copy=True)
358
+ imag = numpy.asarray(imag, dtype=numpy.float64, copy=True)
359
+
360
+ if mean.shape != real.shape[-mean.ndim if mean.ndim else 1 :]:
361
+ raise ValueError(f'{mean.shape=} != {real.shape=}')
362
+ if real.shape != imag.shape:
363
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
364
+
365
+ has_harmonic_axis = mean.ndim + 1 == real.ndim
366
+ if not has_harmonic_axis:
367
+ raise ValueError('no harmonic axis')
368
+ if harmonic is None:
369
+ harmonics, _ = parse_harmonic('all', real.shape[0])
370
+ else:
371
+ harmonics, _ = parse_harmonic(harmonic, None)
372
+ if len(harmonics) < 2:
373
+ raise ValueError(
374
+ 'at least two harmonics required, ' f'got {len(harmonics)}'
375
+ )
376
+ if len(harmonics) != real.shape[0]:
377
+ raise ValueError(
378
+ 'number of harmonics does not match first axis of real and imag'
379
+ )
380
+
381
+ mean = numpy.asarray(numpy.nan_to_num(mean, copy=False))
382
+ real = numpy.asarray(numpy.nan_to_num(real, copy=False))
383
+ imag = numpy.asarray(numpy.nan_to_num(imag, copy=False))
384
+ real *= mean
385
+ imag *= mean
386
+
387
+ mean_expanded = numpy.broadcast_to(mean, real.shape).copy()
388
+ original_mean_expanded = mean_expanded.copy()
389
+ real_filtered = real.copy()
390
+ imag_filtered = imag.copy()
391
+
392
+ _, axes = parse_skip_axis(skip_axis, mean.ndim, True)
393
+
394
+ for index in numpy.ndindex(
395
+ *(
396
+ real.shape[ax]
397
+ for ax in range(real.ndim)
398
+ if ax not in axes and ax != 0
399
+ )
400
+ ):
401
+ index_list: list[int | slice] = list(index)
402
+ for ax in axes:
403
+ index_list = index_list[:ax] + [slice(None)] + index_list[ax:]
404
+ full_index = tuple(index_list)
405
+
406
+ processed_harmonics = set()
407
+
408
+ for h in harmonics:
409
+ if h in processed_harmonics and (
410
+ h * 4 in harmonics or h * 2 not in harmonics
411
+ ):
412
+ continue
413
+ if h * 2 not in harmonics:
414
+ raise ValueError(
415
+ f'harmonic {h} does not have a corresponding half '
416
+ f'or double harmonic in {harmonics}'
417
+ )
418
+ n = harmonics.index(h)
419
+ n2 = harmonics.index(h * 2)
420
+
421
+ complex_phasor = numpy.empty(
422
+ (3, *original_mean_expanded[n][full_index].shape),
423
+ dtype=numpy.complex128,
424
+ )
425
+ complex_phasor[0] = original_mean_expanded[n][full_index]
426
+ complex_phasor[1] = real[n][full_index] + 1j * imag[n][full_index]
427
+ complex_phasor[2] = (
428
+ real[n2][full_index] + 1j * imag[n2][full_index]
429
+ )
430
+
431
+ complex_phasor = pawflim(
432
+ complex_phasor, n_sigmas=sigma, levels=levels
433
+ )
434
+
435
+ for i, idx in enumerate([n, n2]):
436
+ if harmonics[idx] in processed_harmonics:
437
+ continue
438
+ mean_expanded[idx][full_index] = complex_phasor[0].real
439
+ real_filtered[idx][full_index] = complex_phasor[i + 1].real
440
+ imag_filtered[idx][full_index] = complex_phasor[i + 1].imag
441
+
442
+ processed_harmonics.add(h)
443
+ processed_harmonics.add(h * 2)
444
+
445
+ with numpy.errstate(divide='ignore', invalid='ignore'):
446
+ real = numpy.asarray(numpy.divide(real_filtered, mean_expanded))
447
+ imag = numpy.asarray(numpy.divide(imag_filtered, mean_expanded))
448
+
449
+ return mean, real, imag
450
+
451
+
452
+ def phasor_threshold(
453
+ mean: ArrayLike,
454
+ real: ArrayLike,
455
+ imag: ArrayLike,
456
+ /,
457
+ mean_min: ArrayLike | None = None,
458
+ mean_max: ArrayLike | None = None,
459
+ *,
460
+ real_min: ArrayLike | None = None,
461
+ real_max: ArrayLike | None = None,
462
+ imag_min: ArrayLike | None = None,
463
+ imag_max: ArrayLike | None = None,
464
+ phase_min: ArrayLike | None = None,
465
+ phase_max: ArrayLike | None = None,
466
+ modulation_min: ArrayLike | None = None,
467
+ modulation_max: ArrayLike | None = None,
468
+ open_interval: bool = False,
469
+ detect_harmonics: bool = True,
470
+ **kwargs: Any,
471
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
472
+ """Return phasor coordinates with values outside interval replaced by NaN.
473
+
474
+ Interval thresholds can be set for mean intensity, real and imaginary
475
+ coordinates, and phase and modulation.
476
+ Phasor coordinates smaller than minimum thresholds or larger than maximum
477
+ thresholds are replaced with NaN.
478
+ No threshold is applied by default.
479
+ NaNs in `mean` or any `real` and `imag` harmonic are propagated to
480
+ `mean` and all harmonics in `real` and `imag`.
481
+
482
+ Parameters
483
+ ----------
484
+ mean : array_like
485
+ Intensity of phasor coordinates.
486
+ real : array_like
487
+ Real component of phasor coordinates.
488
+ imag : array_like
489
+ Imaginary component of phasor coordinates.
490
+ mean_min : array_like, optional
491
+ Lower threshold for mean intensity.
492
+ mean_max : array_like, optional
493
+ Upper threshold for mean intensity.
494
+ real_min : array_like, optional
495
+ Lower threshold for real coordinates.
496
+ real_max : array_like, optional
497
+ Upper threshold for real coordinates.
498
+ imag_min : array_like, optional
499
+ Lower threshold for imaginary coordinates.
500
+ imag_max : array_like, optional
501
+ Upper threshold for imaginary coordinates.
502
+ phase_min : array_like, optional
503
+ Lower threshold for phase angle.
504
+ phase_max : array_like, optional
505
+ Upper threshold for phase angle.
506
+ modulation_min : array_like, optional
507
+ Lower threshold for modulation.
508
+ modulation_max : array_like, optional
509
+ Upper threshold for modulation.
510
+ open_interval : bool, optional
511
+ If true, the interval is open, and the threshold values are
512
+ not included in the interval.
513
+ If false (default), the interval is closed, and the threshold values
514
+ are included in the interval.
515
+ detect_harmonics : bool, optional
516
+ By default, detect presence of multiple harmonics from array shapes.
517
+ If false, no harmonics are assumed to be present, and the function
518
+ behaves like a numpy universal function.
519
+ **kwargs
520
+ Optional `arguments passed to numpy universal functions
521
+ <https://numpy.org/doc/stable/reference/ufuncs.html#ufuncs-kwargs>`_.
522
+
523
+ Returns
524
+ -------
525
+ mean : ndarray
526
+ Thresholded intensity of phasor coordinates.
527
+ real : ndarray
528
+ Thresholded real component of phasor coordinates.
529
+ imag : ndarray
530
+ Thresholded imaginary component of phasor coordinates.
531
+
532
+ Examples
533
+ --------
534
+ Set phasor coordinates to NaN if mean intensity is smaller than 1.1:
535
+
536
+ >>> phasor_threshold([1, 2, 3], [0.1, 0.2, 0.3], [0.4, 0.5, 0.6], 1.1)
537
+ (array([nan, 2, 3]), array([nan, 0.2, 0.3]), array([nan, 0.5, 0.6]))
538
+
539
+ Set phasor coordinates to NaN if real component is smaller than 0.15 or
540
+ larger than 0.25:
541
+
542
+ >>> phasor_threshold(
543
+ ... [1.0, 2.0, 3.0],
544
+ ... [0.1, 0.2, 0.3],
545
+ ... [0.4, 0.5, 0.6],
546
+ ... real_min=0.15,
547
+ ... real_max=0.25,
548
+ ... )
549
+ (array([nan, 2, nan]), array([nan, 0.2, nan]), array([nan, 0.5, nan]))
550
+
551
+ Apply NaNs to other input arrays:
552
+
553
+ >>> phasor_threshold(
554
+ ... [numpy.nan, 2, 3], [0.1, 0.2, 0.3], [0.4, 0.5, numpy.nan]
555
+ ... )
556
+ (array([nan, 2, nan]), array([nan, 0.2, nan]), array([nan, 0.5, nan]))
557
+
558
+ """
559
+ threshold_mean_only = None
560
+ if mean_min is None:
561
+ mean_min = numpy.nan
562
+ else:
563
+ threshold_mean_only = True
564
+ if mean_max is None:
565
+ mean_max = numpy.nan
566
+ else:
567
+ threshold_mean_only = True
568
+ if real_min is None:
569
+ real_min = numpy.nan
570
+ else:
571
+ threshold_mean_only = False
572
+ if real_max is None:
573
+ real_max = numpy.nan
574
+ else:
575
+ threshold_mean_only = False
576
+ if imag_min is None:
577
+ imag_min = numpy.nan
578
+ else:
579
+ threshold_mean_only = False
580
+ if imag_max is None:
581
+ imag_max = numpy.nan
582
+ else:
583
+ threshold_mean_only = False
584
+ if phase_min is None:
585
+ phase_min = numpy.nan
586
+ else:
587
+ threshold_mean_only = False
588
+ if phase_max is None:
589
+ phase_max = numpy.nan
590
+ else:
591
+ threshold_mean_only = False
592
+ if modulation_min is None:
593
+ modulation_min = numpy.nan
594
+ else:
595
+ threshold_mean_only = False
596
+ if modulation_max is None:
597
+ modulation_max = numpy.nan
598
+ else:
599
+ threshold_mean_only = False
600
+
601
+ if detect_harmonics:
602
+ mean = numpy.asarray(mean)
603
+ real = numpy.asarray(real)
604
+ imag = numpy.asarray(imag)
605
+
606
+ shape = numpy.broadcast_shapes(mean.shape, real.shape, imag.shape)
607
+ ndim = len(shape)
608
+
609
+ has_harmonic_axis = (
610
+ # detect multi-harmonic in axis 0
611
+ mean.ndim + 1 == ndim
612
+ and real.shape == shape
613
+ and imag.shape == shape
614
+ and mean.shape == shape[-mean.ndim if mean.ndim else 1 :]
615
+ )
616
+ else:
617
+ has_harmonic_axis = False
618
+
619
+ if threshold_mean_only is None:
620
+ mean, real, imag = _phasor_threshold_nan(mean, real, imag, **kwargs)
621
+
622
+ elif threshold_mean_only:
623
+ mean_func = (
624
+ _phasor_threshold_mean_open
625
+ if open_interval
626
+ else _phasor_threshold_mean_closed
627
+ )
628
+ mean, real, imag = mean_func(
629
+ mean, real, imag, mean_min, mean_max, **kwargs
630
+ )
631
+
632
+ else:
633
+ func = (
634
+ _phasor_threshold_open
635
+ if open_interval
636
+ else _phasor_threshold_closed
637
+ )
638
+ mean, real, imag = func(
639
+ mean,
640
+ real,
641
+ imag,
642
+ mean_min,
643
+ mean_max,
644
+ real_min,
645
+ real_max,
646
+ imag_min,
647
+ imag_max,
648
+ phase_min,
649
+ phase_max,
650
+ modulation_min,
651
+ modulation_max,
652
+ **kwargs,
653
+ )
654
+
655
+ mean = numpy.asarray(mean)
656
+ real = numpy.asarray(real)
657
+ imag = numpy.asarray(imag)
658
+ if has_harmonic_axis and mean.ndim > 0:
659
+ # propagate NaN to all dimensions
660
+ mean = numpy.mean(mean, axis=0, keepdims=True)
661
+ mask = numpy.where(numpy.isnan(mean), numpy.nan, 1.0)
662
+ numpy.multiply(real, mask, out=real)
663
+ numpy.multiply(imag, mask, out=imag)
664
+ # remove harmonic dimension created by broadcasting
665
+ mean = numpy.asarray(numpy.asarray(mean)[0])
666
+
667
+ return mean, real, imag
668
+
669
+
670
+ def signal_filter_svd(
671
+ signal: ArrayLike,
672
+ /,
673
+ spectral_vector: ArrayLike | None = None,
674
+ *,
675
+ axis: int = -1,
676
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
677
+ sigma: float = 0.05,
678
+ vmin: float | None = None,
679
+ dtype: DTypeLike | None = None,
680
+ num_threads: int | None = None,
681
+ ) -> NDArray[Any]:
682
+ """Return spectral-vector-denoised signal.
683
+
684
+ The spectral vector denoising algorithm is based on a Gaussian weighted
685
+ average calculation, with weights obtained in n-dimensional Chebyshev or
686
+ Fourier space [2]_.
687
+
688
+ Parameters
689
+ ----------
690
+ signal : array_like
691
+ Hyperspectral data to be denoised.
692
+ A minimum of three samples are required along `axis`.
693
+ The samples must be uniformly spaced.
694
+ spectral_vector : array_like, optional
695
+ Spectral vector.
696
+ For example, phasor coordinates, PCA projected phasor coordinates,
697
+ or Chebyshev coefficients.
698
+ Must be of the same shape as `signal` with `axis` removed and an axis
699
+ containing spectral space appended.
700
+ If None (default), phasor coordinates are calculated at specified
701
+ `harmonic`.
702
+ axis : int, optional, default: -1
703
+ Axis over which `spectral_vector` is computed if not provided.
704
+ The default is the last axis (-1).
705
+ harmonic : int, sequence of int, or 'all', optional
706
+ Harmonics to include in calculating `spectral_vector`.
707
+ If `'all'`, include all harmonics for `signal` samples along `axis`.
708
+ Else, harmonics must be at least one and no larger than half the
709
+ number of `signal` samples along `axis`.
710
+ The default is the first harmonic (fundamental frequency).
711
+ A minimum of `harmonic * 2 + 1` samples are required along `axis`
712
+ to calculate correct phasor coordinates at `harmonic`.
713
+ sigma : float, default: 0.05
714
+ Width of Gaussian filter in spectral vector space.
715
+ Weighted averages are calculated using the spectra of signal items
716
+ within a spectral vector Euclidean distance of `3 * sigma` and
717
+ intensity above `vmin`.
718
+ vmin : float, optional
719
+ Signal intensity along `axis` below which spectra are excluded from
720
+ denoising.
721
+ dtype : dtype_like, optional
722
+ Data type of output arrays. Either float32 or float64.
723
+ The default is float64 unless the `signal` is float32.
724
+ num_threads : int, optional
725
+ Number of OpenMP threads to use for parallelization.
726
+ By default, multi-threading is disabled.
727
+ If zero, up to half of logical CPUs are used.
728
+ OpenMP may not be available on all platforms.
729
+
730
+ Returns
731
+ -------
732
+ ndarray
733
+ Denoised signal of `dtype`.
734
+ Spectra with integrated intensity below `vmin` are unchanged.
735
+
736
+ References
737
+ ----------
738
+ .. [2] Harman RC, Lang RT, Kercher EM, Leven P, and Spring BQ.
739
+ `Denoising multiplexed microscopy images in n-dimensional spectral space
740
+ <https://doi.org/10.1364/BOE.463979>`_.
741
+ *Biomed Opt Express*, 13(8): 4298-4309 (2022)
742
+
743
+ Notes
744
+ -----
745
+ This implementation is considered experimental. It is not validated
746
+ against the reference implementation and may not be practical with
747
+ real-world data. See discussion in `issue #142
748
+ <https://github.com/phasorpy/phasorpy/issues/142#issuecomment-2499421491>`_.
749
+
750
+ Examples
751
+ --------
752
+ Denoise a hyperspectral image with a Gaussian filter width of 0.1 in
753
+ spectral vector space using first and second harmonic:
754
+
755
+ >>> signal = numpy.random.randint(0, 255, (8, 16, 16))
756
+ >>> signal_filter_svd(signal, axis=0, sigma=0.1, harmonic=[1, 2])
757
+ array([[[...]]])
758
+
759
+ """
760
+ num_threads = number_threads(num_threads)
761
+
762
+ signal = numpy.asarray(signal)
763
+ if axis == -1 or axis == signal.ndim - 1:
764
+ axis = -1
765
+ else:
766
+ signal = numpy.moveaxis(signal, axis, -1)
767
+ shape = signal.shape
768
+ samples = shape[-1]
769
+
770
+ if harmonic is None:
771
+ harmonic = 1
772
+ harmonic, _ = parse_harmonic(harmonic, samples // 2)
773
+ num_harmonics = len(harmonic)
774
+
775
+ if vmin is None or vmin < 0.0:
776
+ vmin = 0.0
777
+
778
+ signal = numpy.ascontiguousarray(signal).reshape(-1, samples)
779
+ size = signal.shape[0]
780
+
781
+ if dtype is None:
782
+ if signal.dtype.char == 'f':
783
+ dtype = signal.dtype
784
+ else:
785
+ dtype = numpy.float64
786
+ dtype = numpy.dtype(dtype)
787
+ if dtype.char not in {'d', 'f'}:
788
+ raise ValueError('dtype is not floating point')
789
+
790
+ if spectral_vector is None:
791
+ sincos = numpy.empty((num_harmonics, samples, 2))
792
+ for i, h in enumerate(harmonic):
793
+ phase = numpy.linspace(
794
+ 0,
795
+ h * math.pi * 2.0,
796
+ samples,
797
+ endpoint=False,
798
+ dtype=numpy.float64,
799
+ )
800
+ sincos[i, :, 0] = numpy.cos(phase)
801
+ sincos[i, :, 1] = numpy.sin(phase)
802
+ spectral_vector = numpy.zeros((size, num_harmonics * 2), dtype=dtype)
803
+
804
+ _phasor_from_signal_vector(
805
+ spectral_vector, signal, sincos, num_threads
806
+ )
807
+ else:
808
+ spectral_vector = numpy.ascontiguousarray(spectral_vector, dtype=dtype)
809
+ if spectral_vector.shape[:-1] != shape[:-1]:
810
+ raise ValueError('signal and spectral_vector shape mismatch')
811
+ spectral_vector = spectral_vector.reshape(
812
+ -1, spectral_vector.shape[-1]
813
+ )
814
+
815
+ if dtype == signal.dtype:
816
+ denoised = signal.copy()
817
+ else:
818
+ denoised = numpy.zeros(signal.shape, dtype=dtype)
819
+ denoised[:] = signal
820
+ integrated = numpy.zeros(size, dtype=dtype)
821
+ _signal_denoise_vector(
822
+ denoised, integrated, signal, spectral_vector, sigma, vmin, num_threads
823
+ )
824
+
825
+ denoised = denoised.reshape(shape) # type: ignore[assignment]
826
+ if axis != -1:
827
+ denoised = numpy.moveaxis(denoised, -1, axis)
828
+ return denoised
829
+
830
+
831
+ def signal_filter_ncpca(
832
+ signal: ArrayLike,
833
+ /,
834
+ n_components: int | float | str | None = 3,
835
+ *,
836
+ axis: int = -1,
837
+ dtype: DTypeLike | None = None,
838
+ **kwargs: Any,
839
+ ) -> NDArray[Any]:
840
+ """Return signal filtered by noise-corrected principal component analysis.
841
+
842
+ Apply noise-corrected Principal Component Analysis (NC-PCA) to denoise
843
+ signal containing shot noise. The signal is Poisson-normalized,
844
+ its dimensionality reduced by PCA with a specified number of components,
845
+ and then reconstructed according to [3]_.
846
+
847
+ Parameters
848
+ ----------
849
+ signal : array_like
850
+ Data containing Poisson noise to be filtered.
851
+ Must have at least 3 samples along the specified `axis`.
852
+ n_components : int, float, or str, optional, default: 3
853
+ Number of principal components to retain.
854
+ The default is 3, matching the reference implementation.
855
+ If None, all components are kept (no denoising).
856
+ If 'mle', use Minka's MLE to guess the dimension.
857
+ If 0 < n_components < 1 and svd_solver == 'full', select the number
858
+ of components such that the amount of variance that needs to be
859
+ explained is greater than the percentage specified by n_components.
860
+ See `sklearn.decomposition.PCA
861
+ <https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html>`_
862
+ for more details.
863
+ axis : int, optional, default: -1
864
+ Axis containing PCA features, for example, FLIM histogram bins.
865
+ The default is the last axis (-1).
866
+ Other axes are flattened and used as PCA samples.
867
+ dtype : dtype_like, optional
868
+ Data type of computation and output arrays. Either float32 or float64.
869
+ The default is float64 unless the input `signal` is float32.
870
+ **kwargs
871
+ Optional arguments passed to :py:class:`sklearn.decomposition.PCA`.
872
+
873
+ Returns
874
+ -------
875
+ ndarray
876
+ Denoised signal of specified `dtype`. Values may be negative.
877
+ Values, where the signal mean along `axis` is zero, are set to NaN.
878
+
879
+ Raises
880
+ ------
881
+ ValueError
882
+ If `dtype` is not a floating point type.
883
+ If `signal` has fewer than 3 samples along specified axis.
884
+ If `n_components` is invalid for the data size.
885
+
886
+ References
887
+ ----------
888
+ .. [3] Soltani S, Paulson J, Fong E, Mumenthaler, S, and Armani A.
889
+ `Denoising of fluorescence lifetime imaging data via principal
890
+ component analysis <https://doi.org/10.21203/rs.3.rs-7143126/v1>`_.
891
+ *Preprint*, (2025)
892
+
893
+ Notes
894
+ -----
895
+ Intensities of the reconstructed signal may be negative.
896
+ Hence, the phasor coordinates calculated from the reconstructed signal
897
+ may be outside the unit circle.
898
+ Consider thresholding low intensities for further analysis.
899
+
900
+ Examples
901
+ --------
902
+ Denoise FLIM data using 3 principal components:
903
+
904
+ >>> signal = numpy.random.poisson(100, (32, 32, 64))
905
+ >>> denoised = signal_filter_ncpca(signal, n_components=3)
906
+ >>> denoised.shape
907
+ (32, 32, 64)
908
+
909
+ """
910
+ from sklearn.decomposition import PCA
911
+
912
+ if (
913
+ dtype is None
914
+ and isinstance(signal, numpy.ndarray)
915
+ and signal.dtype == numpy.float32
916
+ ):
917
+ signal = signal.copy()
918
+ dtype = signal.dtype
919
+ else:
920
+ dtype = numpy.dtype(dtype)
921
+ if dtype.char not in {'f', 'd'}:
922
+ raise ValueError(f'{dtype=} is not a floating point type')
923
+ signal = numpy.asarray(signal, dtype=dtype, copy=True)
924
+
925
+ if axis == -1 or axis == signal.ndim - 1:
926
+ axis = -1
927
+ else:
928
+ signal = numpy.moveaxis(signal, axis, -1)
929
+
930
+ shape = signal.shape
931
+
932
+ if signal.size == 0:
933
+ raise ValueError('signal array is empty')
934
+ if signal.shape[-1] < 3:
935
+ raise ValueError(f'{signal.shape[-1]=} < 3')
936
+
937
+ # flatten sample dimensions
938
+ signal = signal.reshape(-1, shape[-1])
939
+
940
+ # poisson-normalize signal
941
+ scale = numpy.sqrt(numpy.nanmean(signal, axis=0, keepdims=True))
942
+ with numpy.errstate(divide='ignore', invalid='ignore'):
943
+ signal /= scale
944
+
945
+ # replace NaN and infinite values
946
+ mask = numpy.logical_or(
947
+ numpy.any(numpy.isnan(signal), axis=-1),
948
+ numpy.any(numpy.isinf(signal), axis=-1),
949
+ )
950
+ assert isinstance(signal, numpy.ndarray) # for Mypy
951
+ signal[mask] = 0.0
952
+
953
+ # PCA transform and reconstruction with n_components
954
+ pca = PCA(n_components, **kwargs)
955
+ signal = pca.inverse_transform(pca.fit_transform(signal))
956
+ del pca
957
+
958
+ # restore original scale and shape
959
+ assert isinstance(signal, numpy.ndarray) # for Mypy
960
+ signal[mask] = numpy.nan
961
+ signal *= scale
962
+ signal = signal.reshape(shape)
963
+ if axis != -1:
964
+ signal = numpy.moveaxis(signal, -1, axis)
965
+
966
+ return signal