prtools 1.3.2__tar.gz → 1.4.1__tar.gz

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.
Files changed (30) hide show
  1. {prtools-1.3.2/prtools.egg-info → prtools-1.4.1}/PKG-INFO +1 -1
  2. {prtools-1.3.2 → prtools-1.4.1}/prtools/__init__.py +11 -2
  3. {prtools-1.3.2 → prtools-1.4.1}/prtools/array.py +9 -5
  4. {prtools-1.3.2 → prtools-1.4.1}/prtools/backend/_jax.py +7 -0
  5. prtools-1.4.1/prtools/convolve.py +339 -0
  6. {prtools-1.3.2 → prtools-1.4.1}/prtools/fourier.py +104 -48
  7. {prtools-1.3.2 → prtools-1.4.1}/prtools/jax.py +66 -8
  8. {prtools-1.3.2 → prtools-1.4.1}/prtools/zernike.py +91 -30
  9. {prtools-1.3.2 → prtools-1.4.1/prtools.egg-info}/PKG-INFO +1 -1
  10. {prtools-1.3.2 → prtools-1.4.1}/prtools.egg-info/SOURCES.txt +2 -0
  11. {prtools-1.3.2 → prtools-1.4.1}/tests/test_array.py +14 -0
  12. prtools-1.4.1/tests/test_convolve.py +75 -0
  13. {prtools-1.3.2 → prtools-1.4.1}/tests/test_fourier.py +20 -30
  14. {prtools-1.3.2 → prtools-1.4.1}/LICENSE +0 -0
  15. {prtools-1.3.2 → prtools-1.4.1}/NOTICE +0 -0
  16. {prtools-1.3.2 → prtools-1.4.1}/README.rst +0 -0
  17. {prtools-1.3.2 → prtools-1.4.1}/prtools/backend/__init__.py +0 -0
  18. {prtools-1.3.2 → prtools-1.4.1}/prtools/backend/_base.py +0 -0
  19. {prtools-1.3.2 → prtools-1.4.1}/prtools/backend/_numpy.py +0 -0
  20. {prtools-1.3.2 → prtools-1.4.1}/prtools/cost.py +0 -0
  21. {prtools-1.3.2 → prtools-1.4.1}/prtools/misc.py +0 -0
  22. {prtools-1.3.2 → prtools-1.4.1}/prtools/segmented.py +0 -0
  23. {prtools-1.3.2 → prtools-1.4.1}/prtools/shape.py +0 -0
  24. {prtools-1.3.2 → prtools-1.4.1}/prtools/sparse.py +0 -0
  25. {prtools-1.3.2 → prtools-1.4.1}/prtools/stats.py +0 -0
  26. {prtools-1.3.2 → prtools-1.4.1}/prtools.egg-info/dependency_links.txt +0 -0
  27. {prtools-1.3.2 → prtools-1.4.1}/prtools.egg-info/requires.txt +0 -0
  28. {prtools-1.3.2 → prtools-1.4.1}/prtools.egg-info/top_level.txt +0 -0
  29. {prtools-1.3.2 → prtools-1.4.1}/pyproject.toml +0 -0
  30. {prtools-1.3.2 → prtools-1.4.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prtools
3
- Version: 1.3.2
3
+ Version: 1.4.1
4
4
  Summary: Utility functions for image-based phase retrieval
5
5
  Author: Andy Kee
6
6
  License: MIT License
@@ -1,4 +1,4 @@
1
- __version__ = '1.3.2'
1
+ __version__ = '1.4.1'
2
2
 
3
3
  from prtools.backend import __backend__, use
4
4
 
@@ -15,6 +15,16 @@ from prtools.array import (
15
15
  medfix
16
16
  )
17
17
 
18
+ from prtools.convolve import (
19
+ fftconv,
20
+ gauss,
21
+ gauss_blur,
22
+ gauss_kernel,
23
+ pixel_kernel,
24
+ pixelate,
25
+ sinc,
26
+ )
27
+
18
28
  from prtools.cost import sserror
19
29
 
20
30
  from prtools.fourier import dft2, idft2
@@ -38,7 +48,6 @@ from prtools.shape import (
38
48
  hexagon,
39
49
  rectangle,
40
50
  spider,
41
- gauss,
42
51
  sin,
43
52
  waffle
44
53
  )
@@ -3,6 +3,7 @@ import warnings
3
3
  from prtools import __backend__
4
4
  from prtools.backend import numpy as np
5
5
  from prtools.backend import scipy
6
+ import prtools.jax
6
7
 
7
8
 
8
9
  def centroid(a, where=None, kind='absolute', indexing='ij'):
@@ -300,11 +301,14 @@ def rebin(img, factor):
300
301
  raise ValueError('rebin is not defined for complex data')
301
302
 
302
303
  if img.ndim == 3:
303
- rebinned_shape = (img.shape[0], img.shape[1]//factor, img.shape[2]//factor)
304
- img_rebinned = np.zeros(rebinned_shape, dtype=img.dtype)
305
- for i in range(img.shape[0]):
306
- img_rebinned[i] = img[i].reshape(rebinned_shape[1], factor,
307
- rebinned_shape[2], factor).sum(-1).sum(1)
304
+ if __backend__ == 'jax':
305
+ return prtools.jax._ndrebin(img, factor)
306
+ else:
307
+ rebinned_shape = (img.shape[0], img.shape[1]//factor, img.shape[2]//factor)
308
+ img_rebinned = np.zeros(rebinned_shape, dtype=img.dtype)
309
+ for i in range(img.shape[0]):
310
+ img_rebinned[i] = img[i].reshape(rebinned_shape[1], factor,
311
+ rebinned_shape[2], factor).sum(-1).sum(1)
308
312
  else:
309
313
  img_rebinned = img.reshape(img.shape[0]//factor, factor, img.shape[1]//factor,
310
314
  factor).sum(-1).sum(1)
@@ -6,6 +6,7 @@ from ._base import BackendLibrary
6
6
  class Numpy(BackendLibrary):
7
7
  def __init__(self):
8
8
  super().__init__(importlib.import_module('jax.numpy'))
9
+ self.jax = importlib.import_module('jax')
9
10
 
10
11
  def broadcast_to(self, array, shape):
11
12
  # jax numpy.broadcast_to expects an array input
@@ -43,6 +44,12 @@ class Numpy(BackendLibrary):
43
44
  a = self.module.asarray(a)
44
45
  return self.module.sum(a, *args, **kwargs)
45
46
 
47
+ def take(self, a, indices, *args, **kwargs):
48
+ # jax numpy.take expects an array input for a and indices
49
+ a = self.module.asarray(a)
50
+ indices = self.module.asarray(indices)
51
+ return self.module.take(a, indices, *args, **kwargs)
52
+
46
53
 
47
54
  class Scipy(BackendLibrary):
48
55
  def __init__(self):
@@ -0,0 +1,339 @@
1
+ from prtools.backend import numpy as np
2
+
3
+
4
+ def fftconv(array, kernel, normalize_kernel=True, fft_array=True,
5
+ fft_kernel=False, fftshift_kernel=False):
6
+ r"""Convolve an array with a kernel using the FFT.
7
+
8
+ The colvolution is computed as
9
+
10
+ .. math::
11
+
12
+ u*v = \mathcal{F}^{-1}\left\{\mathcal{F}\{u\}\cdot\mathcal{F}\{v\}\right\}
13
+
14
+ Parameters
15
+ ----------
16
+ array : array_like
17
+ Array to be convolved with ``kernel``.
18
+ kernel : array_like
19
+ Convolution kernel. Should have the same shape as ``array``.
20
+ normalize_kernel : bool, optional
21
+ If True (default), kernel is normalized so that ``sum(kernel) == 1``.
22
+ fft_array : bool, optional
23
+ If True (default), the array is assumed to be provided in the spatial
24
+ domain and its FFT will be computed by this function. If False, the
25
+ array is assumed to be provided in the frequency domain.
26
+ fft_kernel : bool, optional
27
+ If True, the kernel is assumed to be provided in the spatial
28
+ domain and its FFT will be computed by this function. If False
29
+ (default), the kernel is assumed to be provided in the frequency
30
+ domain.
31
+ fftshift_kernel : bool, optional
32
+ If True, the supplied kernel will be shifted so that its
33
+ zero-frequency (DC) term is placed at the upper left ``(0,0)`` corner
34
+ of the array. Default is False.
35
+
36
+ Returns
37
+ -------
38
+ ndarray
39
+
40
+ See also
41
+ --------
42
+ :func:`~prtools.gauss_blur`
43
+ :func:`~prtools.pixelate`
44
+ """
45
+
46
+ a = np.fft.fft2(array) if fft_array else array
47
+ k = np.fft.fftshift(kernel) if fftshift_kernel else kernel
48
+ k = np.fft.fft2(k) if fft_kernel else k
49
+
50
+ if normalize_kernel:
51
+ k /= np.sum(k)
52
+
53
+ return np.fft.ifft2(a*k).real
54
+
55
+
56
+ def gauss_blur(img, sigma, oversample=1):
57
+ """Blur an image using a Gaussian filter using the FFT.
58
+
59
+ Parameters
60
+ ----------
61
+ img : array_like
62
+ Array to be blurred
63
+ sigma : float or (2,) array_like
64
+ Standard deviation for the Gaussian kernel. Providing two values
65
+ allows for non-symmetric Gaussian interpreted as `(sigma_row,
66
+ sigma_col)`
67
+ oversample : float, optional
68
+ Oversampling factor of `img`. Default is 1.
69
+
70
+ Returns
71
+ -------
72
+ ndarray
73
+
74
+ Examples
75
+ --------
76
+ .. plot::
77
+ :include-source:
78
+ :context: reset
79
+ :scale: 50
80
+
81
+ >>> img = np.zeros((100,100))
82
+ >>> img[0:50,0:50] = 1
83
+ >>> img[50:100,50:100] = 1
84
+ >>> img = np.tile(img, (3,3))
85
+ >>> img_blurred = prtools.gauss_blur(img, sigma=3)
86
+ >>> fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(5,2))
87
+ >>> ax[0].imshow(img, cmap='gray')
88
+ >>> ax[0].set_title('Input image')
89
+ >>> ax[1].imshow(img_blurred, cmap='gray')
90
+ >>> ax[1].set_title('Blurred image')
91
+ """
92
+ img = np.asarray(img)
93
+ kernel = gauss_kernel(img.shape, sigma, oversample, fftshift=False)
94
+ # gauss_kernel always returns a normalized kernel
95
+ return fftconv(img, kernel, normalize_kernel=False, fft_array=True,
96
+ fft_kernel=False, fftshift_kernel=False)
97
+
98
+
99
+ def pixelate(img, oversample=1):
100
+ """Apply the aperture effects of an idealized square pixel using the FFT.
101
+
102
+ Parameters
103
+ ----------
104
+ img : array_like
105
+ Input image
106
+ oversample : float, optional
107
+ Oversampling factor of ``img``. Default is 1.
108
+
109
+ Returns
110
+ -------
111
+ out : ndarray
112
+ Image with pixel sampling effects applied.
113
+
114
+ Notes
115
+ -----
116
+ To avoid the introduction of numerical artifacts, this function should be
117
+ performed on data that is at least 2x oversampled.
118
+ """
119
+ img = np.asarray(img)
120
+ kernel = pixel_kernel(img.shape, oversample=oversample, fftshift=False)
121
+ return fftconv(img, kernel, normalize_kernel=False, fft_array=True,
122
+ fft_kernel=False, fftshift_kernel=False)
123
+
124
+
125
+ def gauss(x1, x2, sigma, indexing='ij', normalize=False):
126
+ """2D Gaussian function
127
+
128
+ Parameters
129
+ ----------
130
+ x1, x2 : array_like
131
+ 1-D arrays representing the grid coordinates
132
+ sigma : float or (2,) array_like
133
+ Standard deviation of Gaussian. Providing two values allows for
134
+ non-symmetric Gaussian interpreted as `(sigma_row, sigma_col)`
135
+ indexing : {'ij', 'xy'}, optional
136
+ Matrix ('ij', default) or Cartesian ('xy') indexing of output.
137
+ normalize : bool, optional
138
+ If True, the output is normalized such that its sum is equal to 1.
139
+ If False (default), the output has max equal to one.
140
+
141
+ Returns
142
+ -------
143
+ ndarray
144
+
145
+ Examples
146
+ --------
147
+ .. plot::
148
+ :include-source:
149
+ :context: reset
150
+ :scale: 50
151
+
152
+ >>> r = np.arange(-128,128)
153
+ >>> c = np.arange(-128,128)
154
+ >>> g = prtools.gauss(r, c, sigma=20)
155
+ >>> plt.imshow(g, cmap='gray')
156
+ """
157
+ xx1, xx2 = np.meshgrid(x1, x2, indexing=indexing)
158
+ sigma = np.broadcast_to(sigma, (2,))
159
+ g = np.exp(-((xx1**2/(2*sigma[0]**2)) + (xx2**2/(2*sigma[1]**2))))
160
+ if normalize:
161
+ g = g / (2*np.pi * np.prod(sigma))
162
+ return g
163
+
164
+
165
+ def sinc(x1, x2, indexing='ij'):
166
+ r"""2D sinc function
167
+
168
+ Parameters
169
+ ----------
170
+ x1, x2 : array_like
171
+ 1-D arrays representing the grid coordinates
172
+ indexing : {'ij', 'xy'}, optional
173
+ Matrix ('ij', default) or Cartesian ('xy') indexing of output.
174
+ normalize : bool, optional
175
+ If True, the output is normalized such that its sum is equal to 1.
176
+ If False (default), the output has max equal to one.
177
+
178
+ Returns
179
+ -------
180
+ ndarray
181
+
182
+ See also
183
+ --------
184
+ :func:`~prtools.pixel_kernel`
185
+
186
+ Notes
187
+ -----
188
+ This function uses ``numpy.sinc``, which computes the sinc function as
189
+
190
+ .. math::
191
+ \mbox{sinc}(x) = \frac{\sin \pi x}{\pi x}
192
+
193
+ Examples
194
+ --------
195
+ .. plot::
196
+ :include-source:
197
+ :context: reset
198
+ :scale: 50
199
+
200
+ >>> r = np.linspace(-3,3,512)
201
+ >>> c = np.linspace(-3,3,512)
202
+ >>> s = prtools.sinc(r, c)
203
+ >>> plt.imshow(s, cmap='gray', extent=[r.min(), r.max(), c.min(), c.max()])
204
+ """
205
+ xx1, xx2 = np.meshgrid(x1, x2, indexing=indexing)
206
+ return np.sinc(xx1) * np.sinc(xx2)
207
+
208
+
209
+ def gauss_kernel(shape, sigma, oversample=1, pixelscale=1.0, fftshift=False):
210
+ """2D Gaussian filter kernel
211
+
212
+ This function returns the Fourier transform of a normalized 2D Gaussian
213
+ function.
214
+
215
+ Parameters
216
+ ----------
217
+ shape : int or tuple of int
218
+ Shape of the kernel
219
+ sigma : float
220
+ Standard deviation of Gaussian. Providing two values allows for a
221
+ non-symmetric Gaussian interpreted as ``(sigma_row, sigma_col)``.
222
+ oversample : float, optional
223
+ Oversampling factor to represent in the kernel. Default is 1.
224
+ pixelscale : scalar or tuple of scalars, optional
225
+ Sample spacing. Providing two values defines non-symmetric sampling
226
+ interpreted as ``(pixelscale_row, pixelscale_col)``. Default is 1.
227
+ fftshift : bool, optional
228
+ If True, the kernel is FFT-shifted so that the zero-frequency (DC)
229
+ term is placed at the center of the returned array. Default is False.
230
+
231
+ Returns
232
+ -------
233
+ ndarray
234
+
235
+ See also
236
+ --------
237
+ :func:`~prtools.fftconv`
238
+ :func:`~prtools.gauss_blur`
239
+ :func:`~prtools.gauss`
240
+
241
+ Examples
242
+ --------
243
+ Create a Gaussian kernel with
244
+
245
+ .. plot::
246
+ :include-source:
247
+ :scale: 50
248
+
249
+ >>> G = prtools.gauss_kernel((256,256), sigma=1, fftshift=True)
250
+ >>> plt.imshow(G, cmap='gray')
251
+
252
+ Oversampling is in effect the same as zero-padding:
253
+
254
+ .. plot::
255
+ :include-source:
256
+ :scale: 50
257
+
258
+ >>> G = prtools.gauss_kernel((512,512), sigma=1, oversample=2,
259
+ ... fftshift=True)
260
+ >>> plt.imshow(G, cmap='gray')
261
+
262
+ For a kernel with custom sample spacing:
263
+
264
+ .. plot::
265
+ :include-source:
266
+ :scale: 50
267
+
268
+ >>> G = prtools.gauss_kernel((256,256), sigma=1, pixelscale=0.25,
269
+ ... fftshift=True)
270
+ >>> plt.imshow(G, cmap='gray')
271
+ """
272
+
273
+ shape = np.broadcast_to(shape, (2,))
274
+ pixelscale = np.broadcast_to(pixelscale, (2,))
275
+ sigma = np.broadcast_to(sigma, (2,))
276
+
277
+ x1 = np.fft.fftfreq(n=shape[0], d=pixelscale[0])
278
+ x2 = np.fft.fftfreq(n=shape[1], d=pixelscale[1])
279
+
280
+ k = gauss(x1, x2, sigma=1/(2*np.pi*sigma*oversample), indexing='ij',
281
+ normalize=False)
282
+
283
+ if fftshift:
284
+ return np.fft.fftshift(k)
285
+ else:
286
+ return k
287
+
288
+
289
+ def pixel_kernel(shape, oversample=1, pixelscale=1.0, fftshift=False):
290
+ r"""2D pixel MTF filter kernel
291
+
292
+ This function returns a normalized 2D sinc function sized to represent
293
+ the transfer function of an idealized square pixel.
294
+
295
+ Parameters
296
+ ----------
297
+ shape : int or tuple of int
298
+ Shape of the kernel
299
+ oversample : float, optional
300
+ Oversampling factor to represent in the kernel. Default is 1.
301
+ pixelscale : scalar or tuple of scalars, optional
302
+ Sample spacing. Providing two values defines non-symmetric sampling
303
+ interpreted as ``(pixelscale_row, pixelscale_col)``. Default is 1.
304
+ fftshift : bool, optional
305
+ If True, the kernel is FFT-shifted so that the zero-frequency (DC)
306
+ term is placed at the center of the returned array. Default is False.
307
+
308
+ Returns
309
+ -------
310
+ ndarray
311
+
312
+ See also
313
+ --------
314
+ :func:`~prtools.fftconv`
315
+ :func:`~prtools.pixelate`
316
+ :func:`~prtools.sinc`
317
+
318
+ Examples
319
+ --------
320
+ .. plot::
321
+ :include-source:
322
+ :context: reset
323
+ :scale: 50
324
+
325
+ >>> pixel_mtf = prtools.pixel_kernel((256,256), 1, fftshift=True)
326
+ >>> plt.imshow(pixel_mtf, cmap='gray', vmin=0)
327
+ """
328
+ shape = np.broadcast_to(shape, (2,))
329
+ pixelscale = np.broadcast_to(pixelscale, (2,))
330
+
331
+ x1 = np.fft.fftfreq(n=shape[0], d=pixelscale[0]) * oversample
332
+ x2 = np.fft.fftfreq(n=shape[1], d=pixelscale[1]) * oversample
333
+
334
+ k = np.abs(sinc(x1, x2, indexing='ij'))
335
+
336
+ if fftshift:
337
+ return np.fft.fftshift(k)
338
+ else:
339
+ return k
@@ -1,9 +1,10 @@
1
1
  from prtools import __backend__
2
2
  from prtools.backend import numpy as np
3
+ import prtools.jax
3
4
 
4
5
 
5
- def dft2(f, alpha, shape=None, shift=(0, 0), offset=(0, 0), unitary=True,
6
- out=None):
6
+ def dft2(f, alpha, shape=None, shift=(0, 0), offset=(0, 0), axes=(-2, -1),
7
+ unitary=True, out=None):
7
8
  r"""Compute the 2-dimensional discrete Fourier Transform.
8
9
 
9
10
  The DFT is defined in one dimension as
@@ -31,10 +32,13 @@ def dft2(f, alpha, shape=None, shift=(0, 0), offset=(0, 0), unitary=True,
31
32
  ``F.shape = (shape[0], shape[1])``. Default is ``f.shape``.
32
33
  shift : array_like, optional
33
34
  Number of pixels in (r,c) to shift the DC pixel in the output plane
34
- with the origin centrally located in the plane. Default is ``(0,0)``.
35
+ with the origin centrally located in the plane. Default is ``(0, 0)``.
35
36
  offset : array_like, optional
36
37
  Number of pixels in (r,c) that the input plane is shifted relative to
37
- the origin. Default is ``(0,0)``.
38
+ the origin. Default is ``(0, 0)``.
39
+ axes : (2,) array_like of ints, optional
40
+ Axes over which to compute the DFT. If not given, the last two axes are
41
+ used.
38
42
  unitary : bool, optional
39
43
  Normalization flag. If ``True``, a normalization is performed on the
40
44
  output such that the DFT operation is unitary and energy is conserved
@@ -42,8 +46,8 @@ def dft2(f, alpha, shape=None, shift=(0, 0), offset=(0, 0), unitary=True,
42
46
  way, the energy in in a limited-area DFT is a fraction of the total
43
47
  energy corresponding to the limited area. Default is ``True``.
44
48
  out : ndarray or None
45
- A location into which the result is stored. If provided, out.shape ==
46
- shape and out.dtype == np.complex. If not provided or None, a
49
+ A location into which the result is stored. If provided, ``out.shape ==
50
+ shape`` and ``out.dtype == np.complex``. If not provided or None, a
47
51
  freshly-allocated array is returned.
48
52
 
49
53
  Returns
@@ -74,38 +78,41 @@ def dft2(f, alpha, shape=None, shift=(0, 0), offset=(0, 0), unitary=True,
74
78
  References
75
79
  ----------
76
80
  [1] Soummer, et. al. Fast computation of Lyot-style coronagraph
77
- propagation (2007)
81
+ propagation (2007)
78
82
  """
79
- return _dftcore(f, alpha, shape, shift, offset, unitary, out, forward=True)
80
83
 
84
+ return _dftcore(f, alpha, shape, shift, offset, axes, unitary,
85
+ forward=True, out=out)
81
86
 
82
- def _dftcore(f, alpha, shape, shift, offset, unitary, out, forward):
83
87
 
84
- #__backend__ = prtools.__backend__
88
+ def _dftcore(f, alpha, shape, shift, offset, axes, unitary, forward, out):
85
89
 
86
- if out is not None:
87
- if __backend__ == 'numpy':
90
+ if __backend__ == 'numpy':
91
+ _mtp = prtools.fourier._multi_dot_three
92
+ if out is not None:
88
93
  if not np.can_cast(complex, out.dtype):
89
94
  raise TypeError(f"Cannot cast complex output to dtype('{out.dtype}')")
90
- elif __backend__ == 'jax':
91
- raise ValueError('JAX backend does not support the out parameter')
92
95
 
93
- alpha_row, alpha_col = np.broadcast_to(alpha, (2,))
96
+ elif __backend__ == 'jax':
97
+ _mtp = prtools.jax._multi_dot_three
98
+ if out is not None:
99
+ raise ValueError('JAX backend does not support the out parameter')
94
100
 
95
101
  f = np.asarray(f)
96
- m, n = f.shape
97
102
 
98
- if shape is None:
99
- shape = (m, n)
100
- M, N = shape
103
+ out_shape, axes = _cook_nd_args(f, shape, axes)
104
+ in_shape = np.take(np.asarray(f.shape), axes)
101
105
 
106
+ m, n = in_shape
107
+ M, N = out_shape
108
+
109
+ alpha_row, alpha_col = np.broadcast_to(alpha, (2,))
102
110
  shift_row, shift_col = np.broadcast_to(shift, (2,))
103
111
  offset_row, offset_col = np.broadcast_to(offset, (2,))
104
112
 
105
- E1, E2 = _dft2_matrices(m, n, M, N, alpha_row, alpha_col, shift_row, shift_col,
106
- offset_row, offset_col, forward)
107
-
108
- F = np.dot(E1.dot(f), E2, out=out)
113
+ E1, E2 = _dft2_matrices(m, n, M, N, alpha_row, alpha_col, shift_row,
114
+ shift_col, offset_row, offset_col, forward)
115
+ F = _mtp(E1, f, E2, axes, out)
109
116
 
110
117
  if unitary:
111
118
  F = np.multiply(F, np.sqrt(np.abs(alpha_row * alpha_col)), out=F)
@@ -117,28 +124,21 @@ def _dftcore(f, alpha, shape, shift, offset, unitary, out, forward):
117
124
  return F
118
125
 
119
126
 
120
- def _dft2_matrices(m, n, M, N, alphar, alphac, shiftr, shiftc, offsetr, offsetc, forward):
127
+ def _dft2_matrices(m, n, M, N, alphar, alphac, shiftr, shiftc, offsetr,
128
+ offsetc, forward):
121
129
  if forward:
122
- sign = -1
130
+ c = -1j
123
131
  else:
124
- sign = 1
132
+ c = 1j
125
133
  R, S, U, V = _dft2_coords(m, n, M, N)
126
- E1 = np.exp(sign*2.0 * 1j * np.pi * alphar * np.outer(R+offsetr, U-shiftr)).T
127
- E2 = np.exp(sign*2.0 * 1j * np.pi * alphac * np.outer(S+offsetc, V-shiftc))
128
- return E1, E2
129
-
130
-
131
- def _idft2_matrices(m, n, M, N, alphar, alphac, shiftr, shiftc, offsetr, offsetc):
132
- R, S, U, V = _dft2_coords(m, n, M, N)
133
- E1 = np.exp(2.0 * 1j * np.pi * alphar * np.outer(R+offsetr, U-shiftr)).T
134
- E2 = np.exp(2.0 * 1j * np.pi * alphac * np.outer(S+offsetc, V-shiftc))
134
+ E1 = np.exp(2.0 * c * np.pi * alphar * np.outer(R+offsetr, U-shiftr)).T
135
+ E2 = np.exp(2.0 * c * np.pi * alphac * np.outer(S+offsetc, V-shiftc))
135
136
  return E1, E2
136
137
 
137
138
 
138
139
  def _dft2_coords(m, n, M, N):
139
140
  # R and S are (r,c) coordinates in the (m x n) input plane f
140
141
  # V and U are (r,c) coordinates in the (M x N) output plane F
141
-
142
142
  R = np.arange(m) - np.floor(m/2.0)
143
143
  S = np.arange(n) - np.floor(n/2.0)
144
144
  U = np.arange(M) - np.floor(M/2.0)
@@ -147,7 +147,51 @@ def _dft2_coords(m, n, M, N):
147
147
  return R, S, U, V
148
148
 
149
149
 
150
- def idft2(F, alpha, shape=None, shift=(0,0), offset=(0,0), unitary=True, out=None):
150
+ def _cook_nd_args(a, s=None, axes=None):
151
+ # slightly modified version of numpy's function of the same name
152
+ if s is None:
153
+ if axes is None:
154
+ if a.ndim == 2:
155
+ s = list(a.shape)
156
+ elif a.ndim == 3:
157
+ s = list(a.shape[1:3])
158
+ else:
159
+ raise ValueError("Array must have ndim == 2 or 3")
160
+ else:
161
+ s = np.take(a.shape, axes)
162
+ s = list(s)
163
+ if axes is None:
164
+ axes = list(range(-len(s), 0))
165
+ if len(s) != len(axes):
166
+ raise ValueError("Shape and axes have different lengths.")
167
+ return s, axes
168
+
169
+
170
+ def _multi_dot_three(a, b, c, axes, out):
171
+ # compute the matrix triple product
172
+ #
173
+ # a few notes:
174
+ # * this method is similar to np.linalg.multi_dot although it is less
175
+ # general - here we only consider the matrix triple product used as
176
+ # a part of prtools.dft2
177
+ # * because we use np.matmul instead of np.linalg.multi_dot, we can
178
+ # take advantage of broadcasting a and c when b.ndim = 3. This
179
+ # eliminates a for loop in the code
180
+ # * the implementation used here supports b with ndim in (2, 3)
181
+ # iterating over any of the 3 axes when b.ndim == 3
182
+ # * the implementation used here is actually slightly faster than
183
+ # an equivalent call to np.linalg.multi_dot when b.ndim == 2
184
+ # * np.linalg.multi_dot chooses the fastest multiplication order from
185
+ # [(ab)c, a(bc)] depending on the shapes of a, b, and c. There is no
186
+ # difference when computing the dft because both a and c are square
187
+ # matrices
188
+ ab = np.matmul(a, b, axes=[(0, 1), axes, axes])
189
+ out = np.matmul(ab, c, axes=[axes, (0, 1), axes], out=out)
190
+ return out
191
+
192
+
193
+ def idft2(F, alpha, shape=None, shift=(0, 0), offset=(0, 0), axes=(-2, -1),
194
+ unitary=True, out=None):
151
195
  r"""Compute the 2-dimensional inverse discrete Fourier Transform.
152
196
 
153
197
  The IDFT is defined in one dimension as
@@ -165,9 +209,9 @@ def idft2(F, alpha, shape=None, shift=(0,0), offset=(0,0), unitary=True, out=Non
165
209
  F : array_like
166
210
  2D array to Fourier Transform
167
211
  alpha : float or array_like
168
- Input plane sampling interval (frequency). If :attr:`alpha` is an array,
169
- ``alpha[1]`` represents row-wise sampling and ``alpha[2]`` represents
170
- column-wise sampling. If :attr:`alpha` is a scalar,
212
+ Input plane sampling interval (frequency). If :attr:`alpha` is an
213
+ array, ``alpha[1]`` represents row-wise sampling and ``alpha[2]``
214
+ represents column-wise sampling. If :attr:`alpha` is a scalar,
171
215
  ``alpha[1] = alpha[2] = alpha`` represents uniform sampling across the
172
216
  rows and columns of the input plane.
173
217
  shape : int or array_like, optional
@@ -175,14 +219,24 @@ def idft2(F, alpha, shape=None, shift=(0,0), offset=(0,0), unitary=True, out=Non
175
219
  ``F.shape = (shape[0], shape[1])``. If :attr:`shape` is a scalar,
176
220
  ``F.shape = (shape, shape)``. Default is ``F.shape``
177
221
  shift : array_like, optional
178
- Number of pixels in (x,y) to shift the DC pixel in the output plane with
179
- the origin centrally located in the plane. Default is `[0,0]`.
222
+ Number of pixels in (x,y) to shift the DC pixel in the output plane
223
+ with the origin centrally located in the plane. Default is `[0,0]`.
224
+ offset : array_like, optional
225
+ Number of pixels in (r,c) that the input plane is shifted relative to
226
+ the origin. Default is ``(0, 0)``.
227
+ axes : (2,) array_like of ints, optional
228
+ Axes over which to compute the DFT. If not given, the last two axes are
229
+ used.
180
230
  unitary : bool, optional
181
231
  Normalization flag. If ``True``, a normalization is performed on the
182
232
  output such that the DFT operation is unitary and energy is conserved
183
233
  through the Fourier transform operation (Parseval's theorem). In this
184
234
  way, the energy in in a limited-area DFT is a fraction of the total
185
235
  energy corresponding to the limited area. Default is ``True``.
236
+ out : ndarray or None
237
+ A location into which the result is stored. If provided, ``out.shape ==
238
+ shape`` and ``out.dtype == np.complex``. If not provided or None, a
239
+ freshly-allocated array is returned.
186
240
 
187
241
  Returns
188
242
  -------
@@ -205,13 +259,15 @@ def idft2(F, alpha, shape=None, shift=(0,0), offset=(0,0), unitary=True, out=Non
205
259
 
206
260
  * If the y-axis shift behavior is not what you are expecting, you most
207
261
  likely have your plotting axes flipped (matplotlib's default behavior is
208
- to place [0,0] in the upper left corner of the axes). This may be resolved
209
- by either flipping the sign of the y component of ``shift`` or by passing
210
- ``origin = 'lower'`` to ``imshow()``.
262
+ to place [0,0] in the upper left corner of the axes). This may be
263
+ resolved by either flipping the sign of the y component of ``shift`` or
264
+ by passing ``origin = 'lower'`` to ``imshow()``.
211
265
 
212
266
  References
213
267
  ----------
214
- [1] Soummer, et. al. Fast computation of Lyot-style coronagraph propagation (2007)
215
-
268
+ [1] Soummer, et. al. Fast computation of Lyot-style coronagraph
269
+ propagation (2007)
270
+
216
271
  """
217
- return _dftcore(F, alpha, shape, shift, offset, unitary, out, forward=False)
272
+ return _dftcore(F, alpha, shape, shift, offset, axes, unitary,
273
+ forward=False, out=out)
@@ -31,14 +31,22 @@ class JaxOptimizeResult:
31
31
  state: Any #: Optimizer state
32
32
 
33
33
 
34
- def lbfgs(fun, x0, gtol=None, maxiter=None, callback=None):
34
+ def lbfgs(fn, x0, gtol=None, maxiter=None, callback=None, fn_args=None,
35
+ fn_kwargs=None):
35
36
  """Minimize a scalar function of one or more variables using the L-BFGS
36
37
  algorithm
37
38
 
38
39
  Parameters
39
40
  ----------
40
- fun : callable
41
- The objective function to be minimied
41
+ fn : callable
42
+ The objective function to be minimized:
43
+
44
+ .. code:: python
45
+
46
+ fn(x, *fn_args, **fn_kwargs)
47
+
48
+ where ``x`` is a 1-D array with shape (n,) and ``fn_args`` and
49
+ ``fn_kwargs`` are optional positional and keyword arguments.
42
50
  x0 : jax.Array
43
51
  Initial guess
44
52
  gtol : float
@@ -53,6 +61,10 @@ def lbfgs(fun, x0, gtol=None, maxiter=None, callback=None):
53
61
  callback(intermediate_result: JaxOptimizeResult)
54
62
 
55
63
  where ``intermediate_result`` is a :class:`JaxOptimizeResult`.
64
+ fn_args : iterable or None
65
+ Extra positional arguments passed to ``fn()``
66
+ fn_kwargs : dict or None
67
+ Extra keyword arguments passed to the ``fn()``
56
68
 
57
69
  Returns
58
70
  -------
@@ -71,14 +83,21 @@ def lbfgs(fun, x0, gtol=None, maxiter=None, callback=None):
71
83
  if not any((gtol, maxiter)):
72
84
  raise ValueError('At least one termination tolerance must be specified.')
73
85
 
86
+ fn_args = [] if fn_args is None else fn_args
87
+ fn_kwargs = {} if fn_kwargs is None else fn_kwargs
88
+
74
89
  opt = optax.lbfgs()
75
- value_and_grad_fun = optax.value_and_grad_from_state(fun)
76
-
90
+ value_and_grad_fn = optax.value_and_grad_from_state(fn)
91
+
77
92
  def step(carry):
78
93
  params, state = carry
79
- value, grad = value_and_grad_fun(params, state=state)
94
+ # NOTE: passing *args and **kwargs to value_and_grad_fun is very
95
+ # poorly documented in optax (as of v0.2.6 - 10/2025) but this
96
+ # seems to work for now
97
+ value, grad = value_and_grad_fn(params, *fn_args, state=state,
98
+ **fn_kwargs)
80
99
  updates, state = opt.update(
81
- grad, state, params, value=value, grad=grad, value_fn=fun)
100
+ grad, state, params, value=value, grad=grad, value_fn=fn)
82
101
  if callback:
83
102
  res = JaxOptimizeResult(
84
103
  n=otu.tree_get(state, 'count')-1,
@@ -107,8 +126,47 @@ def lbfgs(fun, x0, gtol=None, maxiter=None, callback=None):
107
126
  grad=otu.tree_get(final_state, 'grad'),
108
127
  value=otu.tree_get(final_state, 'value'),
109
128
  state=final_state)
110
-
129
+
111
130
  if callback:
112
131
  jax.debug.callback(callback, res)
113
132
 
114
133
  return res
134
+
135
+
136
+ def _multi_dot_three(a, b, c, axes, out):
137
+ # compute the matrix triple product
138
+ #
139
+ # a few notes:
140
+ # * while numpy-based implementation of this method is based on
141
+ # np.matmul, jax.numpy.matmul doesn't implement the axes argument so
142
+ # we have to use jax.numpy.linalg.multi_dot instead
143
+ # * the implementation used here supports b with ndim in (2, 3)
144
+ # iterating over any of the 3 axes when b.ndim == 3
145
+ # * jax.vmap handles the case when b.ndim == 3 compared with the numpy
146
+ # equivalent of this function which does everything within the
147
+ # confines of matmul using the axes argument
148
+ if b.ndim == 2:
149
+ return jax.numpy.linalg.multi_dot((a, b, c))
150
+ else:
151
+ iter_axis = _iter_axis(axes)
152
+ return jax.vmap(_multi_dot, in_axes=[None, iter_axis, None], out_axes=iter_axis)(a, b, c)
153
+
154
+ def _multi_dot(a, b, c):
155
+ # wrapper function to support vmap call signature
156
+ return jax.numpy.linalg.multi_dot((a, b, c))
157
+
158
+
159
+ def _iter_axis(axes):
160
+ # pure Python to avoid dealing with JAX array mutability issues
161
+ mask = [0, 1, 2]
162
+ for ax in axes:
163
+ mask[ax] = None
164
+ return [ax for ax in mask if ax is not None][0]
165
+
166
+
167
+ def _ndrebin(a, f):
168
+ return jax.vmap(_rebin, in_axes=[0, None])(a, f)
169
+
170
+
171
+ def _rebin(a, f):
172
+ return jax.numpy.reshape(a, (a.shape[0]//f, f, a.shape[1]//f, f)).sum(-1).sum(1)
@@ -4,7 +4,7 @@ import prtools
4
4
  from prtools.backend import numpy as np
5
5
 
6
6
 
7
- def zernike(mask, index, normalize=True, rho=None, theta=None):
7
+ def zernike(mask, index, normalize=True, order='noll', rho=None, theta=None):
8
8
  """Compute the circular Zernike polynomial for a given mask.
9
9
 
10
10
  Parameters
@@ -17,6 +17,8 @@ def zernike(mask, index, normalize=True, rho=None, theta=None):
17
17
  normalize : bool, optional
18
18
  If True (default), the output is normalized according to [1]. If False,
19
19
  the output value ranges [-1, 1] over the mask.
20
+ order : {'noll', 'fringe', 'ansi'}, optional
21
+ Zernike ordering scheme. Default is 'noll'.
20
22
  rho : array_like, optional
21
23
  Radial coordinates of the mask array. :attr:`rho` should be 0 at the
22
24
  origin and 1 at the edge of the circle.
@@ -44,9 +46,15 @@ def zernike(mask, index, normalize=True, rho=None, theta=None):
44
46
 
45
47
  """
46
48
  mask = np.asarray(mask)
47
- #out = np.zeros_like(mask)
48
- #mask_slice = util.boundary_slice(mask, pad=0)
49
- #mask = mask[mask_slice]
49
+
50
+ if order == 'noll':
51
+ n, m = noll_to_nm(index)
52
+ elif order == 'fringe':
53
+ n, m = fringe_to_nm(index)
54
+ elif order =='ansi':
55
+ n, m = ansi_to_nm(index)
56
+ else:
57
+ raise ValueError('Unknown order', order)
50
58
 
51
59
  if rho is None:
52
60
  rho, theta = zernike_coordinates(mask)
@@ -54,8 +62,6 @@ def zernike(mask, index, normalize=True, rho=None, theta=None):
54
62
  if theta is None:
55
63
  raise ValueError("Both rho and theta must be specified")
56
64
 
57
- m, n = zernike_index(index)
58
-
59
65
  if m == 0:
60
66
  if n == 0:
61
67
  Z = mask
@@ -97,26 +103,24 @@ def R(m, n, rho):
97
103
  return R
98
104
 
99
105
 
100
- def zernike_compose(mask, coeffs, normalize=True, rho=None, theta=None):
106
+ def zernike_compose(mask, coeffs, normalize=True, order='noll', rho=None, theta=None):
101
107
  """Create an OPD based on the supplied Zernike coefficients.
102
108
 
103
109
  Parameters
104
110
  ----------
105
111
  mask : array_like
106
112
  Binary mask defining the extent to compute the Zernike polynomial over.
107
-
108
113
  coeffs : array_like
109
114
  List of coefficients corresponding to Zernike indices (Noll ordering)
110
115
  used to create the OPD.
111
-
112
116
  normalize : bool, optional
113
117
  If True (default), the output is normalized according to [1]. If False,
114
118
  the output value ranges [-1, 1] over the mask.
115
-
119
+ order : {'noll', 'fringe', 'ansi'}, optional
120
+ Zernike ordering scheme. Default is 'noll'.
116
121
  rho : array_like, optional
117
122
  Radial coordinates of the mask array. :attr:`rho` should be 0 at the
118
123
  origin and 1 at the edge of the circle.
119
-
120
124
  theta : array_like, optional
121
125
  Angular coordinates of the mask array in radians.
122
126
 
@@ -159,11 +163,12 @@ def zernike_compose(mask, coeffs, normalize=True, rho=None, theta=None):
159
163
  opd = np.zeros(mask.shape)
160
164
 
161
165
  for index, coeff in enumerate(coeffs):
162
- opd += coeff * zernike(mask, index+1, normalize, rho, theta)
166
+ opd += coeff * zernike(mask, index+1, normalize, order, rho, theta)
163
167
 
164
168
  return opd
165
169
 
166
- def zernike_basis(mask, modes, vectorize=False, normalize=True, rho=None, theta=None):
170
+ def zernike_basis(mask, modes, vectorize=False, normalize=True, order='noll',
171
+ rho=None, theta=None):
167
172
  """Compute a Zernike basis set for a given mask.
168
173
 
169
174
  Parameters
@@ -180,6 +185,8 @@ def zernike_basis(mask, modes, vectorize=False, normalize=True, rho=None, theta=
180
185
  normalize : bool, optional
181
186
  If True (default), the output is normalized according to [1]. If False,
182
187
  the output value ranges [-1, 1] over the mask.
188
+ order : {'noll', 'fringe', 'ansi'}, optional
189
+ Zernike ordering scheme. Default is 'noll'.
183
190
  rho : array_like, optional
184
191
  Radial coordinates of the mask array. :attr:`rho` should be 0 at the
185
192
  origin and 1 at the edge of the circle.
@@ -203,7 +210,7 @@ def zernike_basis(mask, modes, vectorize=False, normalize=True, rho=None, theta=
203
210
 
204
211
  basis = []
205
212
  for index, mode in enumerate(modes):
206
- basis.append(zernike(mask, mode, normalize, rho, theta))
213
+ basis.append(zernike(mask, mode, normalize, order, rho, theta))
207
214
  basis = np.asarray(basis)
208
215
 
209
216
  if vectorize:
@@ -211,9 +218,10 @@ def zernike_basis(mask, modes, vectorize=False, normalize=True, rho=None, theta=
211
218
  return basis.reshape(basis.shape[0], -1)
212
219
  else:
213
220
  return basis
214
-
215
221
 
216
- def zernike_fit(opd, mask, modes, normalize=True, rho=None, theta=None):
222
+
223
+ def zernike_fit(opd, mask, modes, normalize=True, order='noll', rho=None,
224
+ theta=None):
217
225
  """Fit a Zernike basis set to an OPD.
218
226
 
219
227
  Parameters
@@ -227,6 +235,8 @@ def zernike_fit(opd, mask, modes, normalize=True, rho=None, theta=None):
227
235
  normalize : bool, optional
228
236
  If True (default), the output is normalized according to [1]. If False,
229
237
  the output value ranges [-1, 1] over the mask.
238
+ order : {'noll', 'fringe', 'ansi'}, optional
239
+ Zernike ordering scheme. Default is 'noll'.
230
240
  rho : array_like, optional
231
241
  Radial coordinates of the mask array. :attr:`rho` should be 0 at the
232
242
  origin and 1 at the edge of the circle.
@@ -263,14 +273,14 @@ def zernike_fit(opd, mask, modes, normalize=True, rho=None, theta=None):
263
273
  opd = np.asarray(opd)
264
274
  mask = np.asarray(mask)
265
275
 
266
- basis = zernike_basis(mask, modes, True, normalize, rho, theta)
276
+ basis = zernike_basis(mask, modes, True, normalize, order, rho, theta)
267
277
 
268
278
  basis = np.linalg.pinv(basis)
269
279
 
270
280
  return np.einsum('ij,i->j', basis, opd.ravel())
271
281
 
272
282
 
273
- def zernike_remove(opd, mask, modes, rho=None, theta=None):
283
+ def zernike_remove(opd, mask, modes, order='noll', rho=None, theta=None):
274
284
  """Fit and remove a Zernike basis set from an OPD.
275
285
 
276
286
  Parameters
@@ -281,6 +291,8 @@ def zernike_remove(opd, mask, modes, rho=None, theta=None):
281
291
  Binary mask defining the extent to compute the Zernike basis over.
282
292
  modes : array_like
283
293
  List of modes (Noll ordering) to remove.
294
+ order : {'noll', 'fringe', 'ansi'}, optional
295
+ Zernike ordering scheme. Default is 'noll'.
284
296
  rho : array_like, optional
285
297
  Radial coordinates of the mask array. :attr:`rho` should be 0 at the
286
298
  origin and 1 at the edge of the circle.
@@ -303,35 +315,34 @@ def zernike_remove(opd, mask, modes, rho=None, theta=None):
303
315
  opd = np.asarray(opd)
304
316
  mask = np.asarray(mask)
305
317
 
306
- coeffs = zernike_fit(opd, mask, modes, rho, theta)
307
- fit_opd = zernike_compose(mask, coeffs, rho, theta)
318
+ coeffs = zernike_fit(opd, mask, modes, True, order, rho, theta)
319
+ fit_opd = zernike_compose(mask, coeffs, True, order, rho, theta)
308
320
 
309
321
  residual = opd - fit_opd
310
322
 
311
323
  return residual
312
324
 
313
325
 
314
- def zernike_index(j):
315
- """Convert a 1-D Noll index to a 2-D radiul and azimuthal index.
326
+ def noll_to_nm(j):
327
+ """Convert Noll index to (n, m) index.
316
328
 
317
329
  Parameters
318
330
  ----------
319
331
  j : int
320
- Noll Zernike index.
332
+ Noll Zernike index
321
333
 
322
334
  Returns
323
335
  -------
324
336
  n : int
325
- Radial Zernike index.
337
+ Radial degree.
326
338
  m : int
327
- Azimuthal Zernike index.
328
-
339
+ Azimuthal degree.
329
340
  """
330
341
  if j < 1:
331
- raise ValueError('Zernike index j must be a positive integer')
342
+ raise ValueError('Noll index must be greater than zero')
332
343
 
333
344
  # find the row j is in
334
- n = int(np.ceil((-1 + np.sqrt(1 + 8*j)) / 2) - 1)
345
+ n = int(np.ceil((-1 + np.sqrt(1 + 8 * j)) / 2) - 1)
335
346
  if n == 0:
336
347
  m = 0
337
348
  else:
@@ -349,13 +360,63 @@ def zernike_index(j):
349
360
  else:
350
361
  row_m = [0]
351
362
 
352
- for i in range(int(np.floor(n / 2))):
363
+ for _ in range(n // 2):
353
364
  row_m.append(row_m[-1] + 2)
354
365
  row_m.append(row_m[-1])
355
366
 
356
367
  m = row_m[r] * sign
357
368
 
358
- return m, n
369
+ return int(n), int(m)
370
+
371
+
372
+ def ansi_to_nm(j):
373
+ """Convert ANSI index to (n, m) index.
374
+
375
+ Parameters
376
+ ----------
377
+ j : int
378
+ ANSI index
379
+
380
+ Returns
381
+ -------
382
+ n : int
383
+ Radial degree
384
+ m : int
385
+ Azimuthal degree
386
+ """
387
+ if j < 0:
388
+ raise ValueError('ANSI index must be zero or larger')
389
+
390
+ # https://en.wikipedia.org/wiki/Zernike_polynomials
391
+ n = np.floor((np.sqrt(8 * j + 1) - 1) / 2)
392
+ m = 2*j - n*(n + 2)
393
+ return int(n), int(m)
394
+
395
+
396
+ def fringe_to_nm(j):
397
+ """Convert Fringe index to (n, m) index.
398
+
399
+ Parameters
400
+ ----------
401
+ j : int
402
+ Fringe index
403
+
404
+ Returns
405
+ -------
406
+ n : int
407
+ Radial degree
408
+ m : int
409
+ Azimuthal degree
410
+ """
411
+ if j < 1:
412
+ raise ValueError('Fringe index must be greater than zero')
413
+
414
+ # https://github.com/arielmission-space/PAOS/blob/main/paos/classes/zernike.py
415
+ m_n = 2 * (np.ceil(np.sqrt(j)) - 1)
416
+ g_s = (m_n / 2) ** 2 + 1
417
+ n = m_n / 2 + np.floor((j - g_s) / 2)
418
+ m = (m_n - n) * (1 - np.mod(j - g_s, 2) * 2)
419
+ return int(n), int(m)
359
420
 
360
421
 
361
422
  def zernike_coordinates(mask, shift=None, angle=0, indexing='ij'):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prtools
3
- Version: 1.3.2
3
+ Version: 1.4.1
4
4
  Summary: Utility functions for image-based phase retrieval
5
5
  Author: Andy Kee
6
6
  License: MIT License
@@ -4,6 +4,7 @@ README.rst
4
4
  pyproject.toml
5
5
  prtools/__init__.py
6
6
  prtools/array.py
7
+ prtools/convolve.py
7
8
  prtools/cost.py
8
9
  prtools/fourier.py
9
10
  prtools/jax.py
@@ -23,4 +24,5 @@ prtools/backend/_base.py
23
24
  prtools/backend/_jax.py
24
25
  prtools/backend/_numpy.py
25
26
  tests/test_array.py
27
+ tests/test_convolve.py
26
28
  tests/test_fourier.py
@@ -65,3 +65,17 @@ def test_boundary(backend):
65
65
  x = np.zeros((10, 10))
66
66
  x[3:7, 2:8] = 1
67
67
  assert np.array_equal(prtools.boundary(x), (3, 6, 2, 7))
68
+
69
+
70
+ @pytest.mark.parametrize('backend', BACKENDS)
71
+ def test_rebin(backend):
72
+ prtools.use(backend)
73
+ x = np.ones((10, 10))
74
+ assert np.array_equal(prtools.rebin(x, 2), 4*np.ones((5, 5)))
75
+
76
+
77
+ @pytest.mark.parametrize('backend', BACKENDS)
78
+ def test_ndrebin(backend):
79
+ prtools.use(backend)
80
+ x = np.ones((3, 10, 10))
81
+ assert np.array_equal(prtools.rebin(x, 2), 4*np.ones((3, 5, 5)))
@@ -0,0 +1,75 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ import prtools
5
+ from .import BACKENDS
6
+
7
+
8
+ @pytest.mark.parametrize('backend', BACKENDS)
9
+ @pytest.mark.parametrize('shape', ((255,255), (256,256)))
10
+ def test_gauss_kernel(backend, shape):
11
+ prtools.use(backend)
12
+
13
+ # note we need to use a sufficiently large sigma here to avoid wrapping
14
+ # in the FFT-computed version of the kernel
15
+ sigma = 5
16
+ #shape = (256,256)
17
+ G = prtools.gauss_kernel(shape, sigma, fftshift=True)
18
+
19
+ r = np.arange(-shape[0]//2,shape[0]//2)
20
+ c = np.arange(-shape[1]//2,shape[1]//2)
21
+ g = prtools.gauss(r, c, sigma)
22
+ G_fft = np.abs(np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(g))))
23
+ G_fft = G_fft / np.max(G_fft)
24
+
25
+ assert np.allclose(G, G_fft, atol=1e-6)
26
+
27
+
28
+ @pytest.mark.parametrize('backend', BACKENDS)
29
+ @pytest.mark.parametrize('shape', ((255,255), (256,256)))
30
+ def test_gauss_kernel_pixelscale(backend, shape):
31
+ prtools.use(backend)
32
+
33
+ r = np.linspace(-10, 10, shape[0])
34
+ c = np.linspace(-10, 10, shape[1])
35
+ nr, nc = len(r), len(c)
36
+ dr, dc = r[1] - r[0], c[1] - c[0]
37
+
38
+ G = prtools.gauss_kernel((nr, nc), sigma=1, pixelscale=(dr, dc),
39
+ fftshift=True)
40
+
41
+ g = prtools.gauss(r, c, sigma=1)
42
+ G_fft = np.abs(np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(g))))
43
+ G_fft = G_fft / np.max(G_fft)
44
+
45
+ assert np.allclose(G, G_fft, atol=1e-6)
46
+
47
+
48
+ @pytest.mark.parametrize('backend', BACKENDS)
49
+ @pytest.mark.parametrize('shape', ((255,255), (256,256)))
50
+ def test_gauss_kernel_oversample(backend, shape):
51
+ prtools.use(backend)
52
+
53
+ sigma = 5
54
+ oversample = 3
55
+
56
+ G = prtools.gauss_kernel(shape, sigma=sigma, fftshift=False)
57
+ Go = prtools.gauss_kernel((shape[0]*oversample, shape[1]*oversample),
58
+ sigma=sigma, oversample=oversample,
59
+ fftshift=False)
60
+
61
+ assert np.allclose(G[0:100,0:100], Go[0:100,0:100])
62
+
63
+
64
+ @pytest.mark.parametrize('backend', BACKENDS)
65
+ @pytest.mark.parametrize('shape', ((255,255), (256,256)))
66
+ def test_pixel_kernel_oversample(backend, shape):
67
+ prtools.use(backend)
68
+
69
+ oversample = 3
70
+
71
+ P = prtools.pixel_kernel(shape, fftshift=False)
72
+ Po = prtools.pixel_kernel((shape[0]*oversample, shape[1]*oversample),
73
+ oversample=oversample, fftshift=False)
74
+
75
+ assert np.allclose(P[0:100,0:100], Po[0:100,0:100])
@@ -6,53 +6,43 @@ from .import BACKENDS
6
6
 
7
7
 
8
8
  @pytest.mark.parametrize('backend', BACKENDS)
9
- def test_dft2_even(backend):
9
+ @pytest.mark.parametrize('shape', ((10,10), (11,11), (10,11)))
10
+ def test_dft2(backend, shape):
10
11
  prtools.use(backend)
11
- n = 10
12
- f = np.random.rand(n, n) + 1j * np.random.rand(n, n)
12
+ m, n = shape
13
+ f = np.random.rand(m, n) + 1j * np.random.rand(m, n)
13
14
 
14
- F_dft = prtools.dft2(f, 1/n, unitary=False)
15
+ F_dft = prtools.dft2(f, [1/m, 1/n], unitary=False)
15
16
  F_fft = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(f)))
16
17
 
17
18
  assert np.allclose(F_dft, F_fft, atol=1e-5)
18
19
 
19
20
 
20
21
  @pytest.mark.parametrize('backend', BACKENDS)
21
- def test_idft2_even(backend):
22
+ @pytest.mark.parametrize('axes', ((0,1), (1,2), (0,2)))
23
+ def test_dft2_cube(backend, axes):
22
24
  prtools.use(backend)
23
- n = 10
24
- F = np.random.rand(n, n) + 1j * np.random.rand(n, n)
25
+ shape = (10, 11, 12)
26
+ m, n = np.take(shape, axes)
27
+ f = np.random.uniform(size=shape) + 1j * np.random.uniform(size=shape)
25
28
 
26
- f_dft = prtools.idft2(F, 1/n, unitary=False)
27
- f_fft = np.fft.fftshift(np.fft.ifft2(np.fft.ifftshift(F)))
29
+ F_dft = prtools.dft2(f, [1/m, 1/n], axes=axes, unitary=False)
30
+ F_fft = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(f), axes=axes))
28
31
 
29
- assert np.allclose(f_dft, f_fft, atol=1e-5)
32
+ assert np.allclose(F_dft, F_fft, atol=5e-5)
30
33
 
31
34
 
32
35
  @pytest.mark.parametrize('backend', BACKENDS)
33
- def test_dft2_odd(backend):
36
+ @pytest.mark.parametrize('shape', ((10,10), (11,11), (10,11)))
37
+ def test_idft2(backend, shape):
34
38
  prtools.use(backend)
35
- n = 11
36
- f = np.random.rand(n, n) + 1j * np.random.rand(n, n)
37
-
38
- F_dft = prtools.dft2(f, 1/n, unitary=False)
39
- F_fft = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(f)))
40
-
41
- print(F_dft.dtype)
42
- print(F_fft.dtype)
43
- assert np.allclose(F_dft, F_fft, atol=1e-5)
44
-
45
-
46
- @pytest.mark.parametrize('backend', BACKENDS)
47
- def test_idft2_odd(backend):
48
- prtools.use(backend)
49
- n = 11
50
- F = np.random.rand(n, n) + 1j * np.random.rand(n, n)
39
+ m, n = shape
40
+ f = np.random.rand(m, n) + 1j * np.random.rand(m, n)
51
41
 
52
- f_dft = prtools.idft2(F, 1/n, unitary=False)
53
- f_fft = np.fft.fftshift(np.fft.ifft2(np.fft.ifftshift(F)))
42
+ F_dft = prtools.idft2(f, [1/m, 1/n], unitary=False)
43
+ F_fft = np.fft.fftshift(np.fft.ifft2(np.fft.ifftshift(f)))
54
44
 
55
- assert np.allclose(f_dft, f_fft, atol=1e-5)
45
+ assert np.allclose(F_dft, F_fft, atol=1e-5)
56
46
 
57
47
 
58
48
  @pytest.mark.parametrize('backend', BACKENDS)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes