phasorpy 0.1__cp311-cp311-win_arm64.whl → 0.2__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.
Binary file
phasorpy/_phasorpy.pyx CHANGED
@@ -61,6 +61,8 @@ ctypedef fused signal_t:
61
61
  float
62
62
  double
63
63
 
64
+ from libc.stdlib cimport free, malloc
65
+
64
66
 
65
67
  def _phasor_from_signal(
66
68
  float_t[:, :, ::1] phasor,
@@ -115,7 +117,7 @@ def _phasor_from_signal(
115
117
  # https://numpy.org/devdocs/reference/c-api/iterator.html
116
118
 
117
119
  if (
118
- samples < 3
120
+ samples < 2
119
121
  or harmonics > samples // 2
120
122
  or phasor.shape[0] != harmonics * 2 + 1
121
123
  or phasor.shape[1] != signal.shape[0]
@@ -428,6 +430,7 @@ def _gaussian_signal(
428
430
  ###############################################################################
429
431
  # FRET model
430
432
 
433
+
431
434
  @cython.ufunc
432
435
  cdef (double, double) _phasor_from_fret_donor(
433
436
  double omega,
@@ -953,6 +956,7 @@ cdef (float_t, float_t) _phasor_divide(
953
956
  ###############################################################################
954
957
  # Geometry ufuncs
955
958
 
959
+
956
960
  @cython.ufunc
957
961
  cdef short _is_inside_range(
958
962
  float_t x, # point
@@ -1559,6 +1563,7 @@ cdef (double, double, double, double) _intersection_circle_line(
1559
1563
  y + (-dd * dx - fabs(dy) * rdd) / dr,
1560
1564
  )
1561
1565
 
1566
+
1562
1567
  ###############################################################################
1563
1568
  # Blend ufuncs
1564
1569
 
@@ -1630,6 +1635,7 @@ cdef float_t _blend_lighten(
1630
1635
  return a
1631
1636
  return <float_t> max(a, b)
1632
1637
 
1638
+
1633
1639
  ###############################################################################
1634
1640
  # Threshold ufuncs
1635
1641
 
@@ -1809,3 +1815,329 @@ cdef (double, double, double) _phasor_threshold_nan(
1809
1815
  return <float_t> NAN, <float_t> NAN, <float_t> NAN
1810
1816
 
1811
1817
  return mean, real, imag
1818
+
1819
+
1820
+ ###############################################################################
1821
+ # Unary ufuncs
1822
+
1823
+
1824
+ @cython.ufunc
1825
+ cdef float_t _anscombe(
1826
+ float_t x,
1827
+ ) noexcept nogil:
1828
+ """Return anscombe variance stabilizing transformation."""
1829
+ if isnan(x):
1830
+ return <float_t> NAN
1831
+
1832
+ return <float_t> (2.0 * sqrt(<double> x + 0.375))
1833
+
1834
+
1835
+ @cython.ufunc
1836
+ cdef float_t _anscombe_inverse(
1837
+ float_t x,
1838
+ ) noexcept nogil:
1839
+ """Return inverse anscombe transformation."""
1840
+ if isnan(x):
1841
+ return <float_t> NAN
1842
+
1843
+ return <float_t> (x * x / 4.0 - 0.375) # 3/8
1844
+
1845
+
1846
+ @cython.ufunc
1847
+ cdef float_t _anscombe_inverse_approx(
1848
+ float_t x,
1849
+ ) noexcept nogil:
1850
+ """Return inverse anscombe transformation.
1851
+
1852
+ Using approximation of exact unbiased inverse.
1853
+
1854
+ """
1855
+ if isnan(x):
1856
+ return <float_t> NAN
1857
+
1858
+ return <float_t> (
1859
+ 0.25 * x * x # 1/4
1860
+ + 0.30618621784789724 / x # 1/4 * sqrt(3/2)
1861
+ - 1.375 / (x * x) # 11/8
1862
+ + 0.7654655446197431 / (x * x * x) # 5/8 * sqrt(3/2)
1863
+ - 0.125 # 1/8
1864
+ )
1865
+
1866
+
1867
+ ###############################################################################
1868
+ # Denoising in spectral space
1869
+
1870
+
1871
+ def _phasor_from_signal_vector(
1872
+ float_t[:, ::1] phasor,
1873
+ const signal_t[:, ::1] signal,
1874
+ const double[:, :, ::1] sincos,
1875
+ const int num_threads
1876
+ ):
1877
+ """Calculate phasor coordinate vectors from signal along last axis.
1878
+
1879
+ Parameters
1880
+ ----------
1881
+ phasor : 2D memoryview of float32 or float64
1882
+ Writable buffer of two dimensions where calculated phasor
1883
+ vectors are stored:
1884
+
1885
+ 0. other dimensions flat
1886
+ 1. real and imaginary components
1887
+
1888
+ signal : 2D memoryview of float32 or float64
1889
+ Buffer of two dimensions containing signal:
1890
+
1891
+ 0. other dimensions flat
1892
+ 1. dimension over which to compute FFT, number samples
1893
+
1894
+ sincos : 3D memoryview of float64
1895
+ Buffer of three dimensions containing sine and cosine terms to be
1896
+ multiplied with signal:
1897
+
1898
+ 0. number harmonics
1899
+ 1. number samples
1900
+ 2. cos and sin
1901
+
1902
+ num_threads : int
1903
+ Number of OpenMP threads to use for parallelization.
1904
+
1905
+ Notes
1906
+ -----
1907
+ This implementation requires contiguous input arrays.
1908
+
1909
+ """
1910
+ cdef:
1911
+ ssize_t size = signal.shape[0]
1912
+ ssize_t samples = signal.shape[1]
1913
+ ssize_t harmonics = sincos.shape[0]
1914
+ ssize_t i, j, k, h
1915
+ double dc, re, im, sample
1916
+
1917
+ if (
1918
+ samples < 2
1919
+ or harmonics > samples // 2
1920
+ or phasor.shape[0] != size
1921
+ or phasor.shape[1] != harmonics * 2
1922
+ ):
1923
+ raise ValueError('invalid shape of phasor or signal')
1924
+ if sincos.shape[1] != samples or sincos.shape[2] != 2:
1925
+ raise ValueError('invalid shape of sincos')
1926
+
1927
+ with nogil, parallel(num_threads=num_threads):
1928
+ for i in prange(signal.shape[0]):
1929
+ j = 0
1930
+ for h in range(harmonics):
1931
+ dc = 0.0
1932
+ re = 0.0
1933
+ im = 0.0
1934
+ for k in range(samples):
1935
+ sample = <double> signal[i, k]
1936
+ dc = dc + sample
1937
+ re = re + sample * sincos[h, k, 0]
1938
+ im = im + sample * sincos[h, k, 1]
1939
+ if dc != 0.0:
1940
+ re = re / dc
1941
+ im = im / dc
1942
+ else:
1943
+ re = NAN if re == 0.0 else re * INFINITY
1944
+ im = NAN if im == 0.0 else im * INFINITY
1945
+ phasor[i, j] = <float_t> re
1946
+ j = j + 1
1947
+ phasor[i, j] = <float_t> im
1948
+ j = j + 1
1949
+
1950
+
1951
+ def _signal_denoise_vector(
1952
+ float_t[:, ::1] denoised,
1953
+ float_t[::1] integrated,
1954
+ const signal_t[:, ::1] signal,
1955
+ const float_t[:, ::1] spectral_vector,
1956
+ const double sigma,
1957
+ const double vmin,
1958
+ const int num_threads
1959
+ ):
1960
+ """Calculate denoised signal from spectral_vector."""
1961
+ cdef:
1962
+ ssize_t size = signal.shape[0]
1963
+ ssize_t samples = signal.shape[1]
1964
+ ssize_t dims = spectral_vector.shape[1]
1965
+ ssize_t i, j, m
1966
+ float_t n
1967
+ double weight, sum, t
1968
+ double sigma2 = -1.0 / (2.0 * sigma * sigma)
1969
+ double threshold = 9.0 * sigma * sigma
1970
+
1971
+ if denoised.shape[0] != size or denoised.shape[1] != samples:
1972
+ raise ValueError('signal and denoised shape mismatch')
1973
+ if integrated.shape[0] != size:
1974
+ raise ValueError('integrated.shape[0] != signal.shape[0]')
1975
+ if spectral_vector.shape[0] != size:
1976
+ raise ValueError('spectral_vector.shape[0] != signal.shape[0]')
1977
+
1978
+ with nogil, parallel(num_threads=num_threads):
1979
+
1980
+ # integrate channel intensities for each pixel
1981
+ # and filter low intensities
1982
+ for i in prange(size):
1983
+ sum = 0.0
1984
+ for m in range(samples):
1985
+ sum = sum + <double> signal[i, m]
1986
+ if sum < vmin:
1987
+ sum = NAN
1988
+ integrated[i] = <float_t> sum
1989
+
1990
+ # loop over all pixels
1991
+ for i in prange(size):
1992
+
1993
+ n = integrated[i]
1994
+ if not n > 0.0:
1995
+ # n is NaN or zero; cannot denoise; return original signal
1996
+ continue
1997
+
1998
+ for m in range(samples):
1999
+ denoised[i, m] /= n # weight = 1.0
2000
+
2001
+ # loop over other pixels
2002
+ for j in range(size):
2003
+ if i == j:
2004
+ # weight = 1.0 already accounted for
2005
+ continue
2006
+
2007
+ n = integrated[j]
2008
+ if not n > 0.0:
2009
+ # n is NaN or zero
2010
+ continue
2011
+
2012
+ # calculate weight from Euclidean distance of
2013
+ # pixels i and j in spectral vector space
2014
+ sum = 0.0
2015
+ for m in range(dims):
2016
+ t = spectral_vector[i, m] - spectral_vector[j, m]
2017
+ sum = sum + t * t
2018
+ if sum > threshold:
2019
+ sum = -1.0
2020
+ break
2021
+ if sum >= 0.0:
2022
+ weight = exp(sum * sigma2) / n
2023
+ else:
2024
+ # sum is NaN or greater than threshold
2025
+ continue
2026
+
2027
+ # add weighted signal[j] to denoised[i]
2028
+ for m in range(samples):
2029
+ denoised[i, m] += <float_t> (weight * signal[j, m])
2030
+
2031
+ # re-normalize to original intensity
2032
+ # sum cannot be zero because integrated == 0 was filtered
2033
+ sum = 0.0
2034
+ for m in range(samples):
2035
+ sum = sum + denoised[i, m]
2036
+ n = <float_t> (<double> integrated[i] / sum)
2037
+ for m in range(samples):
2038
+ denoised[i, m] *= n
2039
+
2040
+
2041
+ ###############################################################################
2042
+ # Filtering functions
2043
+
2044
+
2045
+ cdef float_t _median(float_t *values, const ssize_t size) noexcept nogil:
2046
+ """Return median of array values using Quickselect algorithm."""
2047
+ cdef:
2048
+ ssize_t i, pivot_index, pivot_index_new
2049
+ ssize_t left = 0
2050
+ ssize_t right = size - 1
2051
+ ssize_t middle = size // 2
2052
+ float_t pivot_value, temp
2053
+
2054
+ if size % 2 == 0:
2055
+ middle -= 1 # Quickselect sorts on right
2056
+
2057
+ while left <= right:
2058
+ pivot_index = left + (right - left) // 2
2059
+ pivot_value = values[pivot_index]
2060
+ temp = values[pivot_index]
2061
+ values[pivot_index] = values[right]
2062
+ values[right] = temp
2063
+ pivot_index_new = left
2064
+ for i in range(left, right):
2065
+ if values[i] < pivot_value:
2066
+ temp = values[i]
2067
+ values[i] = values[pivot_index_new]
2068
+ values[pivot_index_new] = temp
2069
+ pivot_index_new += 1
2070
+ temp = values[right]
2071
+ values[right] = values[pivot_index_new]
2072
+ values[pivot_index_new] = temp
2073
+
2074
+ if pivot_index_new == middle:
2075
+ if size % 2 == 0:
2076
+ return (values[middle] + values[middle + 1]) / <float_t> 2.0
2077
+ return values[middle]
2078
+ if pivot_index_new < middle:
2079
+ left = pivot_index_new + 1
2080
+ else:
2081
+ right = pivot_index_new - 1
2082
+
2083
+ return values[middle] # unreachable code?
2084
+
2085
+
2086
+ def _median_filter_2d(
2087
+ float_t[:, :] image,
2088
+ float_t[:, ::1] filtered_image,
2089
+ const ssize_t kernel_size,
2090
+ const int repeat=1,
2091
+ const int num_threads=1,
2092
+ ):
2093
+ """Apply 2D median filter ignoring NaN."""
2094
+ cdef:
2095
+ ssize_t rows = image.shape[0]
2096
+ ssize_t cols = image.shape[1]
2097
+ ssize_t k = kernel_size // 2
2098
+ ssize_t i, j, r, di, dj, ki, kj, valid_count
2099
+ float_t element
2100
+ float_t *kernel
2101
+
2102
+ if kernel_size <= 0:
2103
+ raise ValueError('kernel_size must be greater than 0')
2104
+
2105
+ with nogil, parallel(num_threads=num_threads):
2106
+
2107
+ kernel = <float_t *> malloc(
2108
+ kernel_size * kernel_size * sizeof(float_t)
2109
+ )
2110
+ if kernel == NULL:
2111
+ with gil:
2112
+ raise MemoryError('failed to allocate kernel')
2113
+
2114
+ for r in range(repeat):
2115
+ for i in prange(rows):
2116
+ for j in range(cols):
2117
+ if isnan(image[i, j]):
2118
+ filtered_image[i, j] = <float_t> NAN
2119
+ continue
2120
+ valid_count = 0
2121
+ for di in range(kernel_size):
2122
+ ki = i - k + di
2123
+ if ki < 0:
2124
+ ki = 0
2125
+ elif ki >= rows:
2126
+ ki = rows - 1
2127
+ for dj in range(kernel_size):
2128
+ kj = j - k + dj
2129
+ if kj < 0:
2130
+ kj = 0
2131
+ elif kj >= cols:
2132
+ kj = cols - 1
2133
+ element = image[ki, kj]
2134
+ if not isnan(element):
2135
+ kernel[valid_count] = element
2136
+ valid_count = valid_count + 1
2137
+ filtered_image[i, j] = _median(kernel, valid_count)
2138
+
2139
+ for i in prange(rows):
2140
+ for j in range(cols):
2141
+ image[i, j] = filtered_image[i, j]
2142
+
2143
+ free(kernel)
phasorpy/_utils.py CHANGED
@@ -249,52 +249,65 @@ def phasor_from_polar_scalar(
249
249
 
250
250
  def parse_harmonic(
251
251
  harmonic: int | Sequence[int] | Literal['all'] | str | None,
252
- samples: int,
252
+ harmonic_max: int | None = None,
253
253
  /,
254
254
  ) -> tuple[list[int], bool]:
255
255
  """Return parsed harmonic parameter.
256
256
 
257
+ This function performs common, but not necessarily all, verifications
258
+ of user-provided `harmonic` parameter.
259
+
257
260
  Parameters
258
261
  ----------
259
262
  harmonic : int, list of int, 'all', or None
260
263
  Harmonic parameter to parse.
261
- samples : int
262
- Number of samples in signal.
263
- Used to verify harmonic values and set maximum harmonic value.
264
+ harmonic_max : int, optional
265
+ Maximum value allowed in `hamonic`. Must be one or greater.
266
+ To verify against known number of signal samples,
267
+ pass ``samples // 2``.
268
+ If `harmonic='all'`, a range of harmonics from one to `harmonic_max`
269
+ (included) is returned.
264
270
 
265
271
  Returns
266
272
  -------
267
273
  harmonic : list of int
268
274
  Parsed list of harmonics.
269
275
  has_harmonic_axis : bool
270
- If true, `harmonic` input parameter is an integer, else a list.
276
+ False if `harmonic` input parameter is a scalar integer.
271
277
 
272
278
  Raises
273
279
  ------
274
280
  IndexError
275
- Any element is out of range [1..samples // 2].
281
+ Any element is out of range `[1..harmonic_max]`.
276
282
  ValueError
277
283
  Elements are not unique.
278
284
  Harmonic is empty.
279
285
  String input is not 'all'.
286
+ `harmonic_max` is smaller than 1.
280
287
  TypeError
281
288
  Any element is not an integer.
289
+ `harmonic` is `'all'` and `harmonic_max` is None.
282
290
 
283
291
  """
284
- if samples < 3:
285
- raise ValueError(f'{samples=} < 3')
292
+ if harmonic_max is not None and harmonic_max < 1:
293
+ raise ValueError(f'{harmonic_max=} < 1')
286
294
 
287
295
  if harmonic is None:
288
296
  return [1], False
289
297
 
290
- harmonic_max = samples // 2
291
298
  if isinstance(harmonic, (int, numbers.Integral)):
292
- if harmonic < 1 or harmonic > harmonic_max:
299
+ if harmonic < 1 or (
300
+ harmonic_max is not None and harmonic > harmonic_max
301
+ ):
293
302
  raise IndexError(f'{harmonic=} out of range [1..{harmonic_max}]')
294
303
  return [int(harmonic)], False
295
304
 
296
305
  if isinstance(harmonic, str):
297
306
  if harmonic == 'all':
307
+ if harmonic_max is None:
308
+ raise TypeError(
309
+ f'maximum harmonic must be specified for {harmonic=!r}'
310
+ )
298
311
  return list(range(1, harmonic_max + 1)), True
299
312
  raise ValueError(f'{harmonic=!r} is not a valid harmonic')
300
313
 
@@ -303,10 +316,10 @@ def parse_harmonic(
303
316
  raise ValueError(f'{harmonic=} is empty')
304
317
  if h.dtype.kind not in 'iu' or h.ndim != 1:
305
318
  raise TypeError(f'{harmonic=} element not an integer')
306
- if numpy.any(h < 1) or numpy.any(h > harmonic_max):
307
- raise IndexError(
308
- f'{harmonic=} element out of range [1..{harmonic_max}]'
309
- )
319
+ if numpy.any(h < 1):
320
+ raise IndexError(f'{harmonic=} element < 1')
321
+ if harmonic_max is not None and numpy.any(h > harmonic_max):
322
+ raise IndexError(f'{harmonic=} element > {harmonic_max}]')
310
323
  if numpy.unique(h).size != h.size:
311
324
  raise ValueError(f'{harmonic=} elements must be unique')
312
325
  return h.tolist(), True
phasorpy/datasets.py CHANGED
@@ -14,6 +14,8 @@ Datasets from the following repositories are available:
14
14
  <https://github.com/zoccoler/napari-flim-phasor-plotter/tree/0.0.6/src/napari_flim_phasor_plotter/data>`_
15
15
  - `Phasor-based multi-harmonic unmixing for in-vivo hyperspectral imaging
16
16
  <https://zenodo.org/records/13625087>`_
17
+ - `Convallaria slice acquired with time-resolved 2-photon microscope
18
+ <https://zenodo.org/records/14026720>`_
17
19
 
18
20
  The implementation is based on the `Pooch <https://www.fatiando.org/pooch>`_
19
21
  library.
@@ -287,12 +289,30 @@ ZENODO_13625087 = pooch.create(
287
289
  },
288
290
  )
289
291
 
292
+ CONVALLARIA_FBD = pooch.create(
293
+ path=pooch.os_cache('phasorpy'),
294
+ base_url='doi:10.5281/zenodo.14026719',
295
+ env=ENV,
296
+ registry={
297
+ 'Convallaria_$EI0S.fbd': (
298
+ 'sha256:'
299
+ '3751891b02e3095fedd53a09688d8a22ff2a0083544dd5c0726b9267d11df1bc'
300
+ ),
301
+ 'Calibration_Rhodamine110_$EI0S.fbd': (
302
+ 'sha256:'
303
+ 'd745cbcdd4a10dbaed83ee9f1b150f0c7ddd313031e18233293582cdf10e4691'
304
+ ),
305
+ },
306
+ )
307
+
308
+
290
309
  REPOSITORIES: dict[str, pooch.Pooch] = {
291
310
  'tests': TESTS,
292
311
  'lfd-workshop': LFD_WORKSHOP,
293
312
  'flute': FLUTE,
294
313
  'napari-flim-phasor-plotter': NAPARI_FLIM_PHASOR_PLOTTER,
295
314
  'zenodo-13625087': ZENODO_13625087,
315
+ 'convallaria-fbd': CONVALLARIA_FBD,
296
316
  }
297
317
  """Pooch repositories."""
298
318
 
phasorpy/io.py CHANGED
@@ -290,11 +290,9 @@ def phasor_to_ometiff(
290
290
  imag = imag.reshape(1, -1)
291
291
 
292
292
  if harmonic is not None:
293
- harmonic_array = numpy.atleast_1d(harmonic)
294
- if harmonic_array.ndim > 1 or harmonic_array.size != nharmonic:
293
+ harmonic, _ = parse_harmonic(harmonic)
294
+ if len(harmonic) != nharmonic:
295
295
  raise ValueError('invalid harmonic')
296
- samples = int(harmonic_array.max()) * 2 + 1
297
- harmonic, _ = parse_harmonic(harmonic, samples)
298
296
 
299
297
  if frequency is not None:
300
298
  frequency_array = numpy.atleast_2d(frequency).astype(numpy.float64)
@@ -488,7 +486,7 @@ def phasor_from_ometiff(
488
486
 
489
487
  has_harmonic_dim = tif.series[1].ndim > tif.series[0].ndim
490
488
  nharmonics = tif.series[1].shape[0] if has_harmonic_dim else 1
491
- maxharmonic = nharmonics
489
+ harmonic_max = nharmonics
492
490
  for i in (3, 4):
493
491
  if len(tif.series) < i + 1:
494
492
  break
@@ -499,10 +497,10 @@ def phasor_from_ometiff(
499
497
  elif series.name == 'Phasor harmonic':
500
498
  if not has_harmonic_dim and data.size == 1:
501
499
  attrs['harmonic'] = int(data.item(0))
502
- maxharmonic = attrs['harmonic']
500
+ harmonic_max = attrs['harmonic']
503
501
  elif has_harmonic_dim and data.size == nharmonics:
504
502
  attrs['harmonic'] = data.tolist()
505
- maxharmonic = max(attrs['harmonic'])
503
+ harmonic_max = max(attrs['harmonic'])
506
504
  else:
507
505
  logger.warning(
508
506
  f'harmonic={data} does not match phasor '
@@ -535,7 +533,7 @@ def phasor_from_ometiff(
535
533
  imag = tif.series[2].asarray()
536
534
  else:
537
535
  # specified harmonics
538
- harmonic, keepdims = parse_harmonic(harmonic, 2 * maxharmonic + 1)
536
+ harmonic, keepdims = parse_harmonic(harmonic, harmonic_max)
539
537
  try:
540
538
  if isinstance(harmonic_stored, list):
541
539
  index = [harmonic_stored.index(h) for h in harmonic]
@@ -769,7 +767,7 @@ def phasor_from_simfcs_referenced(
769
767
  else:
770
768
  raise ValueError(f'file extension must be .ref or .r64, not {ext!r}')
771
769
 
772
- harmonic, keep_harmonic_dim = parse_harmonic(harmonic, data.shape[0])
770
+ harmonic, keep_harmonic_dim = parse_harmonic(harmonic, data.shape[0] // 2)
773
771
 
774
772
  mean = data[0].copy()
775
773
  real = numpy.empty((len(harmonic),) + mean.shape, numpy.float32)