From f09a64f174d37457c3775eef49b7d845b1968aaa Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Mon, 26 Aug 2024 16:00:45 +0200 Subject: [PATCH 01/31] enh: add scipy fft interface --- .github/workflows/check.yml | 2 +- examples/fft_options.py | 51 ++++++++++++++++++++++++++++++++++ examples/requirements.txt | 1 + qpretrieve/filter.py | 4 +-- qpretrieve/fourier/__init__.py | 15 ++++++++++ qpretrieve/fourier/base.py | 4 ++- qpretrieve/fourier/ff_scipy.py | 30 ++++++++++++++++++++ qpretrieve/interfere/base.py | 20 ++++++++++--- tests/test_fourier_scipy.py | 15 ++++++++++ tests/test_interfere_base.py | 12 ++++++++ 10 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 examples/fft_options.py create mode 100644 examples/requirements.txt create mode 100644 qpretrieve/fourier/ff_scipy.py create mode 100644 tests/test_fourier_scipy.py create mode 100644 tests/test_interfere_base.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d04fca2..21b78b7 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -10,7 +10,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ['3.10'] + python-version: ['3.10', '3.11'] os: [macos-latest, ubuntu-latest, windows-latest] steps: diff --git a/examples/fft_options.py b/examples/fft_options.py new file mode 100644 index 0000000..1a0821b --- /dev/null +++ b/examples/fft_options.py @@ -0,0 +1,51 @@ +"""Fourier Transform options available + +This example visualizes the different backends and packages available to the +user for performing Fourier transforms. +""" +import matplotlib.pylab as plt +import numpy as np +import qpretrieve +from skimage.restoration import unwrap_phase + +# load the experimental data +edata = np.load("./data/hologram_cell.npz") + +# get the available fft interfaces +interfaces_available = qpretrieve.fourier.get_available_interfaces() + +prange = (-1, 5) +frange = (0, 12) + +results = {} + +for fft_interface in interfaces_available: + holo = qpretrieve.OffAxisHologram(data=edata["data"], + fft_interface=fft_interface) + holo.run_pipeline(filter_name="disk", filter_size=1/2) + bg = qpretrieve.OffAxisHologram(data=edata["bg_data"]) + bg.process_like(holo) + phase = unwrap_phase(holo.phase - bg.phase) + mask = np.log(1 + np.abs(holo.fft_filtered)) + results[fft_interface.__name__] = mask, phase + +num_filters = len(results) + +# plot the properties of `qpi` +fig = plt.figure(figsize=(8, 22)) + +for row, name in enumerate(results): + ax1 = plt.subplot(num_filters, 2, 2*row+1) + ax1.set_title(name, loc="left") + ax1.imshow(results[name][0], vmin=frange[0], vmax=frange[1]) + + ax2 = plt.subplot(num_filters, 2, 2*row+2) + map2 = ax2.imshow(results[name][1], cmap="coolwarm", + vmin=prange[0], vmax=prange[1]) + plt.colorbar(map2, ax=ax2, fraction=.046, pad=0.02, label="phase [rad]") + + ax1.axis("off") + ax2.axis("off") + +plt.tight_layout() +plt.show() diff --git a/examples/requirements.txt b/examples/requirements.txt new file mode 100644 index 0000000..6ccafc3 --- /dev/null +++ b/examples/requirements.txt @@ -0,0 +1 @@ +matplotlib diff --git a/qpretrieve/filter.py b/qpretrieve/filter.py index aa8302c..43de0cc 100644 --- a/qpretrieve/filter.py +++ b/qpretrieve/filter.py @@ -104,8 +104,8 @@ def get_filter_array(filter_name, filter_size, freq_pos, fft_shape): # TODO: avoid the np.roll, instead use the indices directly alpha = 0.1 rsize = int(min(fx.size, fy.size) * filter_size) * 2 - tukey_window_x = signal.tukey(rsize, alpha=alpha).reshape(-1, 1) - tukey_window_y = signal.tukey(rsize, alpha=alpha).reshape(1, -1) + tukey_window_x = signal.windows.tukey(rsize, alpha=alpha).reshape(-1, 1) + tukey_window_y = signal.windows.tukey(rsize, alpha=alpha).reshape(1, -1) tukey = tukey_window_x * tukey_window_y base = np.zeros(fft_shape) s1 = (np.array(fft_shape) - rsize) // 2 diff --git a/qpretrieve/fourier/__init__.py b/qpretrieve/fourier/__init__.py index 62d479c..b9252ef 100644 --- a/qpretrieve/fourier/__init__.py +++ b/qpretrieve/fourier/__init__.py @@ -2,6 +2,7 @@ import warnings from .ff_numpy import FFTFilterNumpy +from .ff_scipy import FFTFilterScipy try: from .ff_pyfftw import FFTFilterPyFFTW @@ -11,6 +12,20 @@ PREFERRED_INTERFACE = None +def get_available_interfaces(): + """Return a list of available FFT algorithms""" + interfaces = [ + FFTFilterPyFFTW, + FFTFilterNumpy, + FFTFilterScipy, + ] + interfaces_available = [] + for interface in interfaces: + if interface is not None and interface.is_available: + interfaces_available.append(interface) + return interfaces_available + + def get_best_interface(): """Return the fastest refocusing interface available diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index 0c430ac..36e758b 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -70,8 +70,10 @@ def __init__(self, data, subtract_mean=True, padding=2, copy=True): else: # convert integer-arrays to floating point arrays dtype = float + if not copy: + copy = None # numpy v2.x behaviour requires asarray with copy=False data_ed = np.array(data, dtype=dtype, copy=copy) - #: original data (with subtracted mean) +#: original data (with subtracted mean) self.origin = data_ed #: whether padding is enabled self.padding = padding diff --git a/qpretrieve/fourier/ff_scipy.py b/qpretrieve/fourier/ff_scipy.py new file mode 100644 index 0000000..81736a2 --- /dev/null +++ b/qpretrieve/fourier/ff_scipy.py @@ -0,0 +1,30 @@ +import scipy as sp + + +from .base import FFTFilter + + +class FFTFilterScipy(FFTFilter): + """Wraps the scipy Fourier transform + """ + # always available, because scipy is a dependency + is_available = True + + def _init_fft(self, data): + """Perform initial Fourier transform of the input data + + Parameters + ---------- + data: 2d real-valued np.ndarray + Input field to be refocused + + Returns + ------- + fft_fdata: 2d complex-valued ndarray + Fourier transform `data` + """ + return sp.fft.fft2(data) + + def _ifft(self, data): + """Perform inverse Fourier transform""" + return sp.fft.ifft2(data) diff --git a/qpretrieve/interfere/base.py b/qpretrieve/interfere/base.py index f1a637a..116fe6c 100644 --- a/qpretrieve/interfere/base.py +++ b/qpretrieve/interfere/base.py @@ -2,7 +2,8 @@ import numpy as np -from ..fourier import get_best_interface +from ..fourier import get_best_interface, get_available_interfaces +from ..fourier.base import FFTFilter class BaseInterferogram(ABC): @@ -15,7 +16,8 @@ class BaseInterferogram(ABC): "invert_phase": False, } - def __init__(self, data, subtract_mean=True, padding=2, copy=True, + def __init__(self, data, fft_interface: FFTFilter = None, + subtract_mean=True, padding=2, copy=True, **pipeline_kws): """ Parameters @@ -38,12 +40,22 @@ def __init__(self, data, subtract_mean=True, padding=2, copy=True, Any additional keyword arguments for :func:`run_pipeline` as defined in :const:`default_pipeline_kws`. """ - ff_iface = get_best_interface() + if fft_interface == 'auto' or fft_interface is None: + self.ff_iface = get_best_interface() + else: + if fft_interface in get_available_interfaces(): + self.ff_iface = fft_interface + else: + raise ValueError( + f"User-chosen FFT Interface '{fft_interface}' is not available. " + f"The available interfaces are: {get_available_interfaces()}.\n" + f"You can use `fft_interface='auto'` to get the best " + f"available interface.") if len(data.shape) == 3: # take the first slice (we have alpha or RGB information) data = data[:, :, 0] #: qpretrieve Fourier transform interface class - self.fft = ff_iface(data=data, + self.fft = self.ff_iface(data=data, subtract_mean=subtract_mean, padding=padding, copy=copy) diff --git a/tests/test_fourier_scipy.py b/tests/test_fourier_scipy.py new file mode 100644 index 0000000..2ccc379 --- /dev/null +++ b/tests/test_fourier_scipy.py @@ -0,0 +1,15 @@ +import numpy as np +import scipy as sp + +from qpretrieve import fourier + + +def test_fft_correct(): + image = np.arange(100).reshape(10, 10) + ff = fourier.FFTFilterScipy(image, subtract_mean=False, padding=False) + assert np.allclose( + sp.fft.ifft2(np.fft.ifftshift(ff.fft_origin)).real, + image, + rtol=0, + atol=1e-8 + ) diff --git a/tests/test_interfere_base.py b/tests/test_interfere_base.py new file mode 100644 index 0000000..2fb622f --- /dev/null +++ b/tests/test_interfere_base.py @@ -0,0 +1,12 @@ +import numpy as np + +import qpretrieve + + +def test_interfere_base_best_interface(): + edata = np.load("./data/hologram_cell.npz") + + holo = qpretrieve.OffAxisHologram(data=edata["data"]) + assert holo.ff_iface.is_available + assert issubclass(holo.ff_iface, qpretrieve.fourier.base.FFTFilter) + assert issubclass(holo.ff_iface, qpretrieve.fourier.ff_numpy.FFTFilterNumpy) From 41b77e465259032f2953ff439c33c4e8764b6ca0 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Mon, 26 Aug 2024 16:04:19 +0200 Subject: [PATCH 02/31] tests: correct path for ci use --- tests/test_interfere_base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_interfere_base.py b/tests/test_interfere_base.py index 2fb622f..a00297a 100644 --- a/tests/test_interfere_base.py +++ b/tests/test_interfere_base.py @@ -1,10 +1,13 @@ +import pathlib import numpy as np import qpretrieve +data_path = pathlib.Path(__file__).parent / "data" + def test_interfere_base_best_interface(): - edata = np.load("./data/hologram_cell.npz") + edata = np.load(data_path / "hologram_cell.npz") holo = qpretrieve.OffAxisHologram(data=edata["data"]) assert holo.ff_iface.is_available From 97e7c5cb57f29e5f741df818758d29a5c334dc60 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Mon, 26 Aug 2024 16:13:59 +0200 Subject: [PATCH 03/31] tests: fft interface checks and lint code --- qpretrieve/filter.py | 6 ++++-- qpretrieve/fourier/base.py | 3 ++- qpretrieve/interfere/base.py | 13 +++++++------ tests/test_interfere_base.py | 29 +++++++++++++++++++++++++++-- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/qpretrieve/filter.py b/qpretrieve/filter.py index 43de0cc..2b22f3b 100644 --- a/qpretrieve/filter.py +++ b/qpretrieve/filter.py @@ -104,8 +104,10 @@ def get_filter_array(filter_name, filter_size, freq_pos, fft_shape): # TODO: avoid the np.roll, instead use the indices directly alpha = 0.1 rsize = int(min(fx.size, fy.size) * filter_size) * 2 - tukey_window_x = signal.windows.tukey(rsize, alpha=alpha).reshape(-1, 1) - tukey_window_y = signal.windows.tukey(rsize, alpha=alpha).reshape(1, -1) + tukey_window_x = signal.windows.tukey( + rsize, alpha=alpha).reshape(-1, 1) + tukey_window_y = signal.windows.tukey( + rsize, alpha=alpha).reshape(1, -1) tukey = tukey_window_x * tukey_window_y base = np.zeros(fft_shape) s1 = (np.array(fft_shape) - rsize) // 2 diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index 36e758b..592638f 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -71,7 +71,8 @@ def __init__(self, data, subtract_mean=True, padding=2, copy=True): # convert integer-arrays to floating point arrays dtype = float if not copy: - copy = None # numpy v2.x behaviour requires asarray with copy=False + # numpy v2.x behaviour requires asarray with copy=False + copy = None data_ed = np.array(data, dtype=dtype, copy=copy) #: original data (with subtracted mean) self.origin = data_ed diff --git a/qpretrieve/interfere/base.py b/qpretrieve/interfere/base.py index 116fe6c..9f7ee56 100644 --- a/qpretrieve/interfere/base.py +++ b/qpretrieve/interfere/base.py @@ -47,8 +47,9 @@ def __init__(self, data, fft_interface: FFTFilter = None, self.ff_iface = fft_interface else: raise ValueError( - f"User-chosen FFT Interface '{fft_interface}' is not available. " - f"The available interfaces are: {get_available_interfaces()}.\n" + f"User-chosen FFT Interface '{fft_interface}' is not " + f"available. The available interfaces are: " + f"{get_available_interfaces()}.\n" f"You can use `fft_interface='auto'` to get the best " f"available interface.") if len(data.shape) == 3: @@ -56,9 +57,9 @@ def __init__(self, data, fft_interface: FFTFilter = None, data = data[:, :, 0] #: qpretrieve Fourier transform interface class self.fft = self.ff_iface(data=data, - subtract_mean=subtract_mean, - padding=padding, - copy=copy) + subtract_mean=subtract_mean, + padding=padding, + copy=copy) #: originally computed Fourier transform self.fft_origin = self.fft.fft_origin #: filtered Fourier data from last run of `run_pipeline` @@ -106,7 +107,7 @@ def compute_filter_size(self, filter_size, filter_size_interpretation, raise ValueError("For sideband distance interpretation, " "`filter_size` must be between 0 and 1; " f"got '{filter_size}'!") - fsize = np.sqrt(np.sum(np.array(sideband_freq)**2)) * filter_size + fsize = np.sqrt(np.sum(np.array(sideband_freq) ** 2)) * filter_size elif filter_size_interpretation == "frequency index": # filter size given in Fourier index (number of Fourier pixels) # The user probably does not know that we are padding in diff --git a/tests/test_interfere_base.py b/tests/test_interfere_base.py index a00297a..484b936 100644 --- a/tests/test_interfere_base.py +++ b/tests/test_interfere_base.py @@ -1,5 +1,6 @@ import pathlib import numpy as np +import pytest import qpretrieve @@ -11,5 +12,29 @@ def test_interfere_base_best_interface(): holo = qpretrieve.OffAxisHologram(data=edata["data"]) assert holo.ff_iface.is_available - assert issubclass(holo.ff_iface, qpretrieve.fourier.base.FFTFilter) - assert issubclass(holo.ff_iface, qpretrieve.fourier.ff_numpy.FFTFilterNumpy) + assert issubclass(holo.ff_iface, + qpretrieve.fourier.base.FFTFilter) + assert issubclass(holo.ff_iface, + qpretrieve.fourier.ff_numpy.FFTFilterNumpy) + + +def test_interfere_base_choose_interface(): + edata = np.load(data_path / "hologram_cell.npz") + + holo = qpretrieve.OffAxisHologram( + data=edata["data"], + fft_interface=qpretrieve.fourier.FFTFilterScipy) + assert holo.ff_iface.is_available + assert issubclass(holo.ff_iface, + qpretrieve.fourier.base.FFTFilter) + assert issubclass(holo.ff_iface, + qpretrieve.fourier.ff_scipy.FFTFilterScipy) + + +def test_interfere_base_bad_interface(): + edata = np.load(data_path / "hologram_cell.npz") + + with pytest.raises(ValueError): + _ = qpretrieve.OffAxisHologram( + data=edata["data"], + fft_interface="MyReallyCoolFFTInterface") From 93618fb1bfee10a7f416d4937e12b1d280d54cdd Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Mon, 26 Aug 2024 17:49:36 +0200 Subject: [PATCH 04/31] docs: update docstrings and docs example output image --- docs/requirements.txt | 7 ++-- docs/sec_code_reference.rst | 17 ++++++++- docs/sec_examples.rst | 2 + examples/fft_options.png | Bin 0 -> 33723 bytes examples/fft_options.py | 68 ++++++++++++++++++++++----------- qpretrieve/filter.py | 2 +- qpretrieve/fourier/__init__.py | 6 +++ qpretrieve/fourier/base.py | 2 +- qpretrieve/fourier/ff_cupy.py | 40 +++++++++++++++++++ qpretrieve/interfere/base.py | 7 ++++ setup.py | 14 ++++--- 11 files changed, 129 insertions(+), 36 deletions(-) create mode 100644 examples/fft_options.png create mode 100644 qpretrieve/fourier/ff_cupy.py diff --git a/docs/requirements.txt b/docs/requirements.txt index 15408de..87e4bc3 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,3 @@ -sphinx==4.3.0 -sphinxcontrib.bibtex>=2.0 -sphinx_rtd_theme==1.0 - +sphinx +sphinxcontrib.bibtex +sphinx_rtd_theme diff --git a/docs/sec_code_reference.rst b/docs/sec_code_reference.rst index dcf88ec..e454188 100644 --- a/docs/sec_code_reference.rst +++ b/docs/sec_code_reference.rst @@ -23,7 +23,6 @@ Fourier transform methods ========================= .. _sec_code_fourier_numpy: - Numpy ----- .. automodule:: qpretrieve.fourier.ff_numpy @@ -31,13 +30,27 @@ Numpy :inherited-members: .. _sec_code_fourier_pyfftw: - PyFFTW ------ .. automodule:: qpretrieve.fourier.ff_pyfftw :members: :inherited-members: +.. _sec_code_fourier_scipy: +Scipy +------ +.. automodule:: qpretrieve.fourier.ff_scipy + :members: + :inherited-members: + +.. _sec_code_fourier_cupy: +Cupy +---- +.. automodule:: qpretrieve.fourier.ff_cupy + :members: + :inherited-members: + + .. _sec_code_ifer: Interference image analysis diff --git a/docs/sec_examples.rst b/docs/sec_examples.rst index b1e99a1..ec01f65 100644 --- a/docs/sec_examples.rst +++ b/docs/sec_examples.rst @@ -12,3 +12,5 @@ Examples .. fancy_include:: filter_visualization.py .. fancy_include:: fourier_scale.py + +.. fancy_include:: fft_options.py diff --git a/examples/fft_options.png b/examples/fft_options.png new file mode 100644 index 0000000000000000000000000000000000000000..5493026926b2b78872d5e678d08353ade5690beb GIT binary patch literal 33723 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�jCL}n7CwR^*p6~Nf?Yh`#aj_dYE!l5Qw{!7AQh$a?fWg#zl6Q-i> z@ka)mhfPop>NUmI1LX{yViu#{YYqjFZWfkmzcuQ4f@Y2}qgE z`nER(WugKO1F}|DDyi3|o^UH=d^V?rLsBnaemq|md#wT)*8|!LgIEp?KVf-b&;y`1 zUNI$kPaF83SMJ3ImuCmceISxqgVRVu`TS}Lt!l#r6F55^T6UmC6yqRbIu=TWx~zN4 zi(84BlZ#99NkWG)?!rR=vJc5qy0P{%{bdj+Osg&f5?bl#!FTrSI6mOhX=l?AADy5rG@VHJ$0MEk;HMN~Y@B+Il%Iz^%Guslq&5v|KOG4gMnDr*{_fAOY18j`P$k)xh@@hYY0dLHI*TS`YrN2a7>N{Euo$q7irxQ?bAu zzzo%0B)2O7Yy$w?(TsH1LlJamA;K;OHtpPK<1m0g$mzfm+D?T;y6!Hya1jxn2b2Kj zwvFxIp9t}>I%qyv4?{4|5F6Kq3x~gaiCBJ!VWEY2w)1@Ft~0N`ZmTYpyBhF#EWe-VwY!OBx*#TL>rW#VOJn(OcJ_U?w3!i^7htFeF`{8 zXwsX#W$%ub%et~+kWtI?#T#c!)xOsdxqHH|iK%kiR3TPKpQza?S~<7`PUciz?g`E@*N`9t-rqs3*n%dgoFej((T)~!(0e1>00LF3JlOeuLAiu80OULZSZEh zXZgpEr*%FT@nCQ5-NB0px?d52ZYm0A&&{S;3eNu=#y@cDo9 byCtWEMzL1HPS2g&;LnvyQWulYYdHTOl?VHX literal 0 HcmV?d00001 diff --git a/examples/fft_options.py b/examples/fft_options.py index 1a0821b..6a2ebe6 100644 --- a/examples/fft_options.py +++ b/examples/fft_options.py @@ -1,12 +1,17 @@ -"""Fourier Transform options available +"""Fourier Transform interfaces available This example visualizes the different backends and packages available to the user for performing Fourier transforms. + +- PyFFTW is initially slow, but over many FFTs is very quick. +- CuPy using CUDA can be very fast, but is currently limited because we are + transferring one image at a time to the GPU. + """ +import time import matplotlib.pylab as plt import numpy as np import qpretrieve -from skimage.restoration import unwrap_phase # load the experimental data edata = np.load("./data/hologram_cell.npz") @@ -14,38 +19,55 @@ # get the available fft interfaces interfaces_available = qpretrieve.fourier.get_available_interfaces() -prange = (-1, 5) -frange = (0, 12) - -results = {} +n_transforms = 100 +# one transform +results_1 = {} for fft_interface in interfaces_available: + t0 = time.time() holo = qpretrieve.OffAxisHologram(data=edata["data"], fft_interface=fft_interface) - holo.run_pipeline(filter_name="disk", filter_size=1/2) + holo.run_pipeline(filter_name="disk", filter_size=1 / 2) bg = qpretrieve.OffAxisHologram(data=edata["bg_data"]) bg.process_like(holo) - phase = unwrap_phase(holo.phase - bg.phase) - mask = np.log(1 + np.abs(holo.fft_filtered)) - results[fft_interface.__name__] = mask, phase + t1 = time.time() + results_1[fft_interface.__name__] = t1 - t0 +num_interfaces = len(results_1) -num_filters = len(results) +# multiple transforms (should see speed increase for PyFFTW) +results = {} +for fft_interface in interfaces_available: + t0 = time.time() + for _ in range(n_transforms): + holo = qpretrieve.OffAxisHologram(data=edata["data"], + fft_interface=fft_interface) + holo.run_pipeline(filter_name="disk", filter_size=1 / 2) + bg = qpretrieve.OffAxisHologram(data=edata["bg_data"]) + bg.process_like(holo) + t1 = time.time() + results[fft_interface.__name__] = t1 - t0 +num_interfaces = len(results) -# plot the properties of `qpi` -fig = plt.figure(figsize=(8, 22)) +fft_interfaces = list(results.keys()) +speed_1 = list(results_1.values()) +speed = list(results.values()) -for row, name in enumerate(results): - ax1 = plt.subplot(num_filters, 2, 2*row+1) - ax1.set_title(name, loc="left") - ax1.imshow(results[name][0], vmin=frange[0], vmax=frange[1]) +fig, axes = plt.subplots(1, 2, figsize=(8, 5)) +ax1, ax2 = axes +labels = [fftstr[9:] for fftstr in fft_interfaces] - ax2 = plt.subplot(num_filters, 2, 2*row+2) - map2 = ax2.imshow(results[name][1], cmap="coolwarm", - vmin=prange[0], vmax=prange[1]) - plt.colorbar(map2, ax=ax2, fraction=.046, pad=0.02, label="phase [rad]") +ax1.bar(range(num_interfaces), height=speed_1, color='lightseagreen') +ax1.set_xticks(range(num_interfaces), labels=labels, + rotation=45) +ax1.set_ylabel("Speed (s)") +ax1.set_title("1 Transform") - ax1.axis("off") - ax2.axis("off") +ax2.bar(range(num_interfaces), height=speed, color='lightseagreen') +ax2.set_xticks(range(num_interfaces), labels=labels, + rotation=45) +ax2.set_ylabel("Speed (s)") +ax2.set_title(f"{n_transforms} Transforms") +plt.suptitle("Speed of FFT Interfaces") plt.tight_layout() plt.show() diff --git a/qpretrieve/filter.py b/qpretrieve/filter.py index 2b22f3b..c7c6ab4 100644 --- a/qpretrieve/filter.py +++ b/qpretrieve/filter.py @@ -38,7 +38,7 @@ def get_filter_array(filter_name, filter_size, freq_pos, fft_shape): and must be between 0 and `max(fft_shape)/2` freq_pos: tuple of floats The position of the filter in frequency coordinates as - returned by :func:`nunpy.fft.fftfreq`. + returned by :func:`numpy.fft.fftfreq`. fft_shape: tuple of int The shape of the Fourier transformed image for which the filter will be applied. The shape must be squared (two diff --git a/qpretrieve/fourier/__init__.py b/qpretrieve/fourier/__init__.py index b9252ef..2d153e8 100644 --- a/qpretrieve/fourier/__init__.py +++ b/qpretrieve/fourier/__init__.py @@ -9,6 +9,11 @@ except ImportError: FFTFilterPyFFTW = None +try: + from .ff_cupy import FFTFilterCupy +except ImportError: + FFTFilterCupy = None + PREFERRED_INTERFACE = None @@ -18,6 +23,7 @@ def get_available_interfaces(): FFTFilterPyFFTW, FFTFilterNumpy, FFTFilterScipy, + FFTFilterCupy, ] interfaces_available = [] for interface in interfaces: diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index 592638f..e5b3f52 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -178,7 +178,7 @@ def filter(self, filter_name: str, filter_size: float, and must be between 0 and `max(fft_shape)/2` freq_pos: tuple of floats The position of the filter in frequency coordinates as - returned by :func:`nunpy.fft.fftfreq`. + returned by :func:`numpy.fft.fftfreq`. scale_to_filter: bool or float Crop the image in Fourier space after applying the filter, effectively removing surplus (zero-padding) data and diff --git a/qpretrieve/fourier/ff_cupy.py b/qpretrieve/fourier/ff_cupy.py new file mode 100644 index 0000000..52e9d6d --- /dev/null +++ b/qpretrieve/fourier/ff_cupy.py @@ -0,0 +1,40 @@ +import scipy as sp +import cupy as cp +import cupyx.scipy.fft as cufft + +from .base import FFTFilter + + +class FFTFilterCupy(FFTFilter): + """Wraps the cupy Fourier transform and uses it via the scipy backend + """ + is_available = True + # sp.fft.set_backend(cufft) + + def _init_fft(self, data): + """Perform initial Fourier transform of the input data + + Parameters + ---------- + data: 2d real-valued np.ndarray + Input field to be refocused + + Returns + ------- + fft_fdata: 2d complex-valued ndarray + Fourier transform `data` + """ + data_gpu = cp.asarray(data) + # likely an inefficiency here, could use `set_global_backend` + with sp.fft.set_backend(cufft): + fft_gpu = sp.fft.fft2(data_gpu) + fft_cpu = fft_gpu.get() + return fft_cpu + + def _ifft(self, data): + """Perform inverse Fourier transform""" + data_gpu = cp.asarray(data) + with sp.fft.set_backend(cufft): + ifft_gpu = sp.fft.ifft2(data_gpu) + ifft_cpu = ifft_gpu.get() + return ifft_cpu diff --git a/qpretrieve/interfere/base.py b/qpretrieve/interfere/base.py index 9f7ee56..93e315a 100644 --- a/qpretrieve/interfere/base.py +++ b/qpretrieve/interfere/base.py @@ -22,6 +22,13 @@ def __init__(self, data, fft_interface: FFTFilter = None, """ Parameters ---------- + fft_interface: FFTFilter + A Fourier transform interface. + See :func:`qpretrieve.fourier.get_available_interfaces` + to get a list of implemented interfaces. + Default is None, which will use + :func:`qpretrieve.fourier.get_best_interface`. This is in line + with old behaviour. subtract_mean: bool If True, remove the mean of the hologram before performing the Fourier transform. This setting is recommended as it diff --git a/setup.py b/setup.py index 3472257..0c2c52b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,6 @@ from setuptools import setup, find_packages import sys - author = u"Paul Müller" authors = [author] description = 'library for phase retrieval from holograms' @@ -27,8 +26,13 @@ "numpy>=1.9.0", "scikit-image>=0.11.0", "scipy>=0.18.0", - ], - extras_require={"FFTW": "pyfftw>=0.12.0"}, + ], + extras_require={ + "FFTW": "pyfftw>=0.12.0", + # manually install 'cupy-cuda11x' if you have older CUDA. + # See https://cupy.dev/ + "CUPY": "cupy-cuda12x", + }, python_requires='>=3.10, <4', keywords=["digital holographic microscopy", "optics", @@ -41,6 +45,6 @@ 'Operating System :: OS Independent', 'Programming Language :: Python :: 3', 'Intended Audience :: Science/Research' - ], + ], platforms=['ALL'], - ) +) From b19a8179ab7b5e5844e41a1406da557c92da4b16 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Tue, 17 Sep 2024 15:17:15 +0200 Subject: [PATCH 05/31] enh, tests: add cupy3D class and basic tests for debugging --- qpretrieve/fourier/__init__.py | 2 ++ qpretrieve/fourier/base.py | 2 +- qpretrieve/fourier/ff_cupy3D.py | 40 +++++++++++++++++++++++++++++++++ qpretrieve/interfere/base.py | 4 +++- tests/test_fourier_cupy.py | 26 +++++++++++++++++++++ tests/test_fourier_cupy3D.py | 26 +++++++++++++++++++++ tests/test_oah_from_qpimage.py | 14 ++++++++++++ 7 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 qpretrieve/fourier/ff_cupy3D.py create mode 100644 tests/test_fourier_cupy.py create mode 100644 tests/test_fourier_cupy3D.py diff --git a/qpretrieve/fourier/__init__.py b/qpretrieve/fourier/__init__.py index 2d153e8..07de88f 100644 --- a/qpretrieve/fourier/__init__.py +++ b/qpretrieve/fourier/__init__.py @@ -11,6 +11,7 @@ try: from .ff_cupy import FFTFilterCupy + from .ff_cupy3D import FFTFilterCupy3D except ImportError: FFTFilterCupy = None @@ -24,6 +25,7 @@ def get_available_interfaces(): FFTFilterNumpy, FFTFilterScipy, FFTFilterCupy, + FFTFilterCupy3D, ] interfaces_available = [] for interface in interfaces: diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index e5b3f52..809e4c7 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -74,7 +74,7 @@ def __init__(self, data, subtract_mean=True, padding=2, copy=True): # numpy v2.x behaviour requires asarray with copy=False copy = None data_ed = np.array(data, dtype=dtype, copy=copy) -#: original data (with subtracted mean) + #: original data (with subtracted mean) self.origin = data_ed #: whether padding is enabled self.padding = padding diff --git a/qpretrieve/fourier/ff_cupy3D.py b/qpretrieve/fourier/ff_cupy3D.py new file mode 100644 index 0000000..3330812 --- /dev/null +++ b/qpretrieve/fourier/ff_cupy3D.py @@ -0,0 +1,40 @@ +import scipy as sp +import cupy as cp +import cupyx.scipy.fft as cufft + +from .base import FFTFilter + + +class FFTFilterCupy3D(FFTFilter): + """Wraps the cupy Fourier transform and uses it via the scipy backend + """ + is_available = True + # sp.fft.set_backend(cufft) + + def _init_fft(self, data): + """Perform initial Fourier transform of the input data + + Parameters + ---------- + data: 2d real-valued np.ndarray + Input field to be refocused + + Returns + ------- + fft_fdata: 2d complex-valued ndarray + Fourier transform `data` + """ + data_gpu = cp.asarray(data) + # likely an inefficiency here, could use `set_global_backend` + with sp.fft.set_backend(cufft): + fft_gpu = sp.fft.fft2(data_gpu) + fft_cpu = fft_gpu.get() + return fft_cpu + + def _ifft(self, data): + """Perform inverse Fourier transform""" + data_gpu = cp.asarray(data) + with sp.fft.set_backend(cufft): + ifft_gpu = sp.fft.ifft2(data_gpu) + ifft_cpu = ifft_gpu.get() + return ifft_cpu diff --git a/qpretrieve/interfere/base.py b/qpretrieve/interfere/base.py index 93e315a..0962c82 100644 --- a/qpretrieve/interfere/base.py +++ b/qpretrieve/interfere/base.py @@ -59,7 +59,9 @@ def __init__(self, data, fft_interface: FFTFilter = None, f"{get_available_interfaces()}.\n" f"You can use `fft_interface='auto'` to get the best " f"available interface.") - if len(data.shape) == 3: + if self.ff_iface.__name__ == "FFTFilterCupy3D": + data = data + elif len(data.shape) == 3: # take the first slice (we have alpha or RGB information) data = data[:, :, 0] #: qpretrieve Fourier transform interface class diff --git a/tests/test_fourier_cupy.py b/tests/test_fourier_cupy.py new file mode 100644 index 0000000..9e06790 --- /dev/null +++ b/tests/test_fourier_cupy.py @@ -0,0 +1,26 @@ +import numpy as np +import scipy as sp + +from qpretrieve import fourier + + +def test_fft_correct(): + image = np.arange(100).reshape(10, 10) + ff = fourier.FFTFilterCupy(image, subtract_mean=False, padding=False) + assert np.allclose( + sp.fft.ifft2(np.fft.ifftshift(ff.fft_origin)).real, + image, + rtol=0, + atol=1e-8 + ) + + +def test_fft_correct_3d(): + image = np.arange(1000).reshape(10, 10, 10) + ff = fourier.FFTFilterCupy(image, subtract_mean=False, padding=False) + assert np.allclose( + sp.fft.ifft2(np.fft.ifftshift(ff.fft_origin)).real, + image, + rtol=0, + atol=1e-8 + ) diff --git a/tests/test_fourier_cupy3D.py b/tests/test_fourier_cupy3D.py new file mode 100644 index 0000000..39bfeeb --- /dev/null +++ b/tests/test_fourier_cupy3D.py @@ -0,0 +1,26 @@ +import numpy as np +import scipy as sp + +from qpretrieve import fourier + + +def test_fft_correct(): + image = np.arange(100).reshape(10, 10) + ff = fourier.FFTFilterCupy3D(image, subtract_mean=False, padding=False) + assert np.allclose( + sp.fft.ifft2(np.fft.ifftshift(ff.fft_origin)).real, + image, + rtol=0, + atol=1e-8 + ) + + +def test_fft_correct_3d(): + image = np.arange(1000).reshape(10, 10, 10) + ff = fourier.FFTFilterCupy3D(image, subtract_mean=False, padding=False) + assert np.allclose( + sp.fft.ifft2(np.fft.ifftshift(ff.fft_origin)).real, + image, + rtol=0, + atol=1e-8 + ) diff --git a/tests/test_oah_from_qpimage.py b/tests/test_oah_from_qpimage.py index 838b180..51f5906 100644 --- a/tests/test_oah_from_qpimage.py +++ b/tests/test_oah_from_qpimage.py @@ -4,6 +4,7 @@ import qpretrieve from qpretrieve.interfere import if_oah +from qpretrieve.fourier import FFTFilterCupy3D def hologram(size=64): @@ -246,3 +247,16 @@ def test_get_field_three_axes(): res1 = holo1.run_pipeline(**kwargs) res2 = holo2.run_pipeline(**kwargs) assert np.all(res1 == res2) + + +def test_get_field_cupy3d(): + data1 = hologram() + data_rp = np.array([data1, data1, data1, data1, data1]) + + holo1 = qpretrieve.OffAxisHologram(data_rp, + fft_interface=FFTFilterCupy3D) + + kwargs = dict(filter_name="disk", + filter_size=1 / 3) + _ = holo1.run_pipeline(**kwargs) + # assert np.all(res1 == res2) From 8525abd1d543243ef64b7f6aab7030a1029cd96c Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Wed, 18 Sep 2024 10:31:54 +0200 Subject: [PATCH 06/31] enh: making the code 3d-proof --- qpretrieve/fourier/base.py | 3 +- qpretrieve/interfere/if_oah.py | 4 ++ tests/test_oah_from_qpimage.py | 87 +++++++++++++++++++++++++++------- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index 809e4c7..976ade2 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -223,7 +223,8 @@ def filter(self, filter_name: str, filter_size: float, filter_name=filter_name, filter_size=filter_size, freq_pos=freq_pos, - fft_shape=self.fft_origin.shape) + # only take shape of a single fft + fft_shape=self.fft_origin.shape[-2:]) fft_filtered = self.fft_origin * filt_array px = int(freq_pos[0] * self.shape[0]) py = int(freq_pos[1] * self.shape[1]) diff --git a/qpretrieve/interfere/if_oah.py b/qpretrieve/interfere/if_oah.py index 73bd4bd..3567501 100644 --- a/qpretrieve/interfere/if_oah.py +++ b/qpretrieve/interfere/if_oah.py @@ -126,6 +126,10 @@ def find_peak_cosine(ft_data, copy=True): if copy: ft_data = ft_data.copy() + if len(ft_data.shape) == 3: + # then we have a stack of images, just take one for finding the peak + ft_data = ft_data[0] + ox, oy = ft_data.shape cx = ox // 2 cy = oy // 2 diff --git a/tests/test_oah_from_qpimage.py b/tests/test_oah_from_qpimage.py index 51f5906..bd5e604 100644 --- a/tests/test_oah_from_qpimage.py +++ b/tests/test_oah_from_qpimage.py @@ -1,10 +1,12 @@ """These are tests from qpimage""" import numpy as np import pytest +import matplotlib.pyplot as plt import qpretrieve from qpretrieve.interfere import if_oah -from qpretrieve.fourier import FFTFilterCupy3D +from qpretrieve.fourier import FFTFilterCupy3D, FFTFilterCupy,\ + FFTFilterNumpy, FFTFilterScipy, FFTFilterPyFFTW def hologram(size=64): @@ -14,14 +16,14 @@ def hologram(size=64): amp = np.linspace(.9, 1.1, size * size).reshape(size, size) pha = np.linspace(0, 2, size * size).reshape(size, size) - rad = x**2 + y**2 > (size / 3)**2 + rad = x ** 2 + y ** 2 > (size / 3) ** 2 pha[rad] = 0 amp[rad] = 1 # frequencies must match pixel in Fourier space kx = 2 * np.pi * -.3 ky = 2 * np.pi * -.3 - image = (amp**2 + np.sin(kx * x + ky * y + pha) + 1) * 255 + image = (amp ** 2 + np.sin(kx * x + ky * y + pha) + 1) * 255 return image @@ -85,11 +87,11 @@ def test_get_field_filter_names(): r_smooth_disk = holo.run_pipeline(filter_name="smooth disk", **kwargs) assert np.allclose(r_smooth_disk[32, 32], - 108.36438759594623-67.1806221692573j) + 108.36438759594623 - 67.1806221692573j) r_gauss = holo.run_pipeline(filter_name="gauss", **kwargs) assert np.allclose(r_gauss[32, 32], - 108.2914187451138-67.1823527237741j) + 108.2914187451138 - 67.1823527237741j) r_square = holo.run_pipeline(filter_name="square", **kwargs) assert np.allclose( @@ -97,7 +99,7 @@ def test_get_field_filter_names(): r_smsquare = holo.run_pipeline(filter_name="smooth square", **kwargs) assert np.allclose( - r_smsquare[32, 32], 108.36651862466393-67.17988960794392j) + r_smsquare[32, 32], 108.36651862466393 - 67.17988960794392j) r_tukey = holo.run_pipeline(filter_name="tukey", **kwargs) assert np.allclose( @@ -122,11 +124,11 @@ def test_get_field_interpretation_fourier_index(size): fsx, fsy = holo.pipeline_kws["sideband_freq"] kwargs1 = dict(filter_name="disk", - filter_size=1/3, + filter_size=1 / 3, filter_size_interpretation="sideband distance") res1 = holo.run_pipeline(**kwargs1) - filter_size_fi = np.sqrt(fsx**2 + fsy**2) / 3 * ft_data.shape[0] + filter_size_fi = np.sqrt(fsx ** 2 + fsy ** 2) / 3 * ft_data.shape[0] kwargs2 = dict(filter_name="disk", filter_size=filter_size_fi, filter_size_interpretation="frequency index", @@ -148,12 +150,12 @@ def test_get_field_interpretation_fourier_index_control(size): evil_factor = 1.1 kwargs1 = dict(filter_name="disk", - filter_size=1/3 * evil_factor, + filter_size=1 / 3 * evil_factor, filter_size_interpretation="sideband distance" ) res1 = holo.run_pipeline(**kwargs1) - filter_size_fi = np.sqrt(fsx**2 + fsy**2) / 3 * ft_data.shape[0] + filter_size_fi = np.sqrt(fsx ** 2 + fsy ** 2) / 3 * ft_data.shape[0] kwargs2 = dict(filter_name="disk", filter_size=filter_size_fi, filter_size_interpretation="frequency index", @@ -179,7 +181,7 @@ def test_get_field_interpretation_fourier_index_mask_1(size, filter_size): # We get 17*2+1, because we measure from the center of Fourier # space and a pixel is included if its center is withing the # perimeter of the disk. - assert np.sum(np.sum(mask, axis=0) != 0) == 17*2 + 1 + assert np.sum(np.sum(mask, axis=0) != 0) == 17 * 2 + 1 @pytest.mark.parametrize("size", [62, 63, 64, 134, 135]) @@ -197,7 +199,7 @@ def test_get_field_interpretation_fourier_index_mask_2(size): # We get two points less than in the previous test, because we # loose on each side of the spectrum. - assert np.sum(np.sum(mask, axis=0) != 0) == 17*2 - 1 + assert np.sum(np.sum(mask, axis=0) != 0) == 17 * 2 - 1 def test_get_field_int_copy(): @@ -254,9 +256,60 @@ def test_get_field_cupy3d(): data_rp = np.array([data1, data1, data1, data1, data1]) holo1 = qpretrieve.OffAxisHologram(data_rp, - fft_interface=FFTFilterCupy3D) + fft_interface=FFTFilterCupy3D, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res1 = holo1.run_pipeline(**kwargs) + assert res1.shape == (5, 64, 64) - kwargs = dict(filter_name="disk", - filter_size=1 / 3) - _ = holo1.run_pipeline(**kwargs) - # assert np.all(res1 == res2) + holo1 = qpretrieve.OffAxisHologram(data1, + fft_interface=FFTFilterNumpy, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res2 = holo1.run_pipeline(**kwargs) + assert res2.shape == (64, 64) + + assert not np.all(res1[0] == res2) + + fig, axes = plt.subplots(3, 1) + ax1, ax2, ax3 = axes + ax1.imshow(np.abs(res1[0])) + ax2.imshow(np.abs(res2)) + ax3.imshow(np.abs(res2)-np.abs(res1[0])) + plt.show() + + +def test_get_field_compare_FFTFilters(): + data1 = hologram() + + holo1 = qpretrieve.OffAxisHologram(data1, + fft_interface=FFTFilterNumpy, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res1 = holo1.run_pipeline(**kwargs) + assert res1.shape == (64, 64) + + holo1 = qpretrieve.OffAxisHologram(data1, + fft_interface=FFTFilterPyFFTW, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res2 = holo1.run_pipeline(**kwargs) + assert res2.shape == (64, 64) + + holo1 = qpretrieve.OffAxisHologram(data1, + fft_interface=FFTFilterScipy, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res3 = holo1.run_pipeline(**kwargs) + assert res3.shape == (64, 64) + + holo1 = qpretrieve.OffAxisHologram(data1, + fft_interface=FFTFilterCupy, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res4 = holo1.run_pipeline(**kwargs) + assert res4.shape == (64, 64) + + assert not np.all(res1 == res2) + assert not np.all(res2 == res3) + assert not np.all(res3 == res4) From 839214e59691678a45e2f635ece6a06e882b9bf5 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Thu, 7 Nov 2024 12:43:48 +0100 Subject: [PATCH 07/31] test: compare 2D and 3D mean value consistency --- tests/test_compare_2D_3D.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/test_compare_2D_3D.py diff --git a/tests/test_compare_2D_3D.py b/tests/test_compare_2D_3D.py new file mode 100644 index 0000000..6a75c20 --- /dev/null +++ b/tests/test_compare_2D_3D.py @@ -0,0 +1,32 @@ +import numpy as np + + +def test_mean_subtraction(): + data_3D = np.random.rand(1000, 5, 5).astype(np.float32) + ind = 5 + data_2D = data_3D.copy()[ind] + + data_2D -= data_2D.mean() + # calculate mean of the images along the z-axis. + # The mean array here is (1000,), so we need to add newaxes for subtraction + # (1000, 5, 5) -= (1000, 1, 1) + data_3D -= data_3D.mean(axis=(-2, -1))[:, np.newaxis, np.newaxis] + + assert np.array_equal(data_3D[ind], data_2D) + + +def test_mean_subtraction_consistent_2D_3D(): + """Probably a bit too cumbersome, and changes the default 2D pipeline.""" + data_3D = np.random.rand(1000, 5, 5).astype(np.float32) + ind = 5 + data_2D = data_3D.copy()[ind] + + # too cumbersome + data_2D = np.atleast_3d(data_2D) + data_2D = np.swapaxes(np.swapaxes(data_2D, 0, 2), 1, 2) + data_2D -= data_2D.mean(axis=(-2, -1))[:, np.newaxis, np.newaxis] + + data_3D = np.atleast_3d(data_3D.copy()) + data_3D -= data_3D.mean(axis=(-2, -1))[:, np.newaxis, np.newaxis] + + assert np.array_equal(data_3D[ind], data_2D[0]) From 7d1ca5525cc6fe1e8ccdbb1e8496659e025eec31 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Thu, 7 Nov 2024 12:48:13 +0100 Subject: [PATCH 08/31] enh: allow fourier base to subtract mean from 3D data arrays --- qpretrieve/fourier/base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index 976ade2..0cc56e6 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -84,7 +84,11 @@ def __init__(self, data, subtract_mean=True, padding=2, copy=True): # remove contributions of the central band # (this affects more than one pixel in the FFT # because of zero-padding) - data_ed -= data_ed.mean() + if len(data_ed.shape) == 2: + data_ed -= data_ed.mean() + elif len(data_ed.shape) == 3: + data_ed -= data_ed.mean( + axis=(-2, -1))[:, np.newaxis,np.newaxis] if padding: # zero padding size is next order of 2 logfact = np.log(padding * max(data_ed.shape)) From 4f0168368db411ba34dff9ad16161e8a40316772 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Thu, 7 Nov 2024 14:32:11 +0100 Subject: [PATCH 09/31] enh: create utility functions for padding and subt_mean --- qpretrieve/fourier/base.py | 33 +++++++++++++++++++++++---------- qpretrieve/utils.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 qpretrieve/utils.py diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index 0cc56e6..1a531a2 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -6,6 +6,7 @@ import numpy as np from .. import filter +from ..utils import padding_2d, padding_3d, mean_2d, mean_3d class FFTCache: @@ -35,12 +36,16 @@ def cleanup(key): class FFTFilter(ABC): - def __init__(self, data, subtract_mean=True, padding=2, copy=True): + def __init__(self, + data: np.ndarray, + subtract_mean: bool = True, + padding: int = 2, + copy: bool = True): r""" Parameters ---------- - data: 2d real-valued np.ndarray - The experimental input image + data + The experimental input image (2d or 3d real-valued) subtract_mean: bool If True, subtract the mean of `data` before performing the Fourier transform. This setting is recommended as it @@ -76,6 +81,7 @@ def __init__(self, data, subtract_mean=True, padding=2, copy=True): data_ed = np.array(data, dtype=dtype, copy=copy) #: original data (with subtracted mean) self.origin = data_ed + # for `subtract_mean` and `padding`, we could use `np.atleast_3d` #: whether padding is enabled self.padding = padding #: whether the mean was subtracted @@ -85,17 +91,24 @@ def __init__(self, data, subtract_mean=True, padding=2, copy=True): # (this affects more than one pixel in the FFT # because of zero-padding) if len(data_ed.shape) == 2: - data_ed -= data_ed.mean() + data_ed = mean_2d(data_ed) elif len(data_ed.shape) == 3: - data_ed -= data_ed.mean( - axis=(-2, -1))[:, np.newaxis,np.newaxis] + data_ed = mean_3d(data_ed) + else: + raise ValueError(f"FFTFilter `data` input must be 2D or 3D, " + f"got {len(data_ed.shape)=}.") if padding: # zero padding size is next order of 2 logfact = np.log(padding * max(data_ed.shape)) order = int(2 ** np.ceil(logfact / np.log(2))) - # this is faster than np.pad - datapad = np.zeros((order, order), dtype=dtype) - datapad[:data_ed.shape[0], :data_ed.shape[1]] = data_ed + + if len(data_ed.shape) == 2: + datapad = padding_2d(data_ed, order, dtype) + elif len(data_ed.shape) == 3: + datapad = padding_3d(data_ed, order, dtype) + else: + raise ValueError(f"FFTFilter `data` input must be 2D or 3D, " + f"got {len(data_ed.shape)=}.") #: padded input data self.origin_padded = datapad data_ed = datapad @@ -257,7 +270,7 @@ def filter(self, filter_name: str, filter_size: float, if scale_to_filter: # Scale the absolute value of the field. This does not # have any influence on the phase, but on the amplitude. - field *= (2 * crad / osize)**2 + field *= (2 * crad / osize) ** 2 # Add FFT to cache # (The cache will only be cleared if this instance is deleted) FFTCache.add_item(weakref_key, self.fft_origin, diff --git a/qpretrieve/utils.py b/qpretrieve/utils.py new file mode 100644 index 0000000..e8142e5 --- /dev/null +++ b/qpretrieve/utils.py @@ -0,0 +1,30 @@ +import numpy as np + + +def mean_2d(data): + data -= data.mean() + return data + + +def mean_3d(data): + # calculate mean of the images along the z-axis. + # The mean array here is (1000,), so we need to add newaxes for subtraction + # (1000, 5, 5) -= (1000, 1, 1) + data -= data.mean(axis=(-2, -1))[:, np.newaxis, np.newaxis] + return data + + +def padding_2d(data, order, dtype): + # this is faster than np.pad + datapad = np.zeros((order, order), dtype=dtype) + # we could of course use np.atleast_3d here + datapad[:data.shape[0], :data.shape[1]] = data + return datapad + + +def padding_3d(data, order, dtype): + z, y, x = data.shape + # this is faster than np.pad + datapad = np.zeros((z, order, order), dtype=dtype) + datapad[:, :y, :x] = data + return datapad From 63b0c4dcfb6fa543eb8831488e098f3849161782 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Thu, 7 Nov 2024 14:32:34 +0100 Subject: [PATCH 10/31] tests: add tests for new util funcs and comparing 2D and 3D Fourier processing --- tests/test_compare_2D_3D.py | 48 ++++++++++++++++++++++-------------- tests/test_fourier_cupy3D.py | 40 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/tests/test_compare_2D_3D.py b/tests/test_compare_2D_3D.py index 6a75c20..c7be6d5 100644 --- a/tests/test_compare_2D_3D.py +++ b/tests/test_compare_2D_3D.py @@ -1,32 +1,44 @@ import numpy as np +from qpretrieve.utils import padding_2d, padding_3d, mean_2d, mean_3d + def test_mean_subtraction(): - data_3D = np.random.rand(1000, 5, 5).astype(np.float32) + data_3d = np.random.rand(1000, 5, 5).astype(np.float32) ind = 5 - data_2D = data_3D.copy()[ind] + data_2d = data_3d.copy()[ind] - data_2D -= data_2D.mean() - # calculate mean of the images along the z-axis. - # The mean array here is (1000,), so we need to add newaxes for subtraction - # (1000, 5, 5) -= (1000, 1, 1) - data_3D -= data_3D.mean(axis=(-2, -1))[:, np.newaxis, np.newaxis] + data_2d = mean_2d(data_2d) + data_3d = mean_3d(data_3d) - assert np.array_equal(data_3D[ind], data_2D) + assert np.array_equal(data_3d[ind], data_2d) -def test_mean_subtraction_consistent_2D_3D(): - """Probably a bit too cumbersome, and changes the default 2D pipeline.""" - data_3D = np.random.rand(1000, 5, 5).astype(np.float32) +def test_mean_subtraction_consistent_2d_3d(): + """Probably a bit too cumbersome, and changes the default 2d pipeline.""" + data_3d = np.random.rand(1000, 5, 5).astype(np.float32) ind = 5 - data_2D = data_3D.copy()[ind] + data_2d = data_3d.copy()[ind] # too cumbersome - data_2D = np.atleast_3d(data_2D) - data_2D = np.swapaxes(np.swapaxes(data_2D, 0, 2), 1, 2) - data_2D -= data_2D.mean(axis=(-2, -1))[:, np.newaxis, np.newaxis] + data_2d = np.atleast_3d(data_2d) + data_2d = np.swapaxes(np.swapaxes(data_2d, 0, 2), 1, 2) + data_2d -= data_2d.mean(axis=(-2, -1))[:, np.newaxis, np.newaxis] + + data_3d = np.atleast_3d(data_3d.copy()) + data_3d -= data_3d.mean(axis=(-2, -1))[:, np.newaxis, np.newaxis] + + assert np.array_equal(data_3d[ind], data_2d[0]) + + +def test_batch_padding(): + data_3d = np.random.rand(1000, 100, 320).astype(np.float32) + ind = 5 + data_2d = data_3d.copy()[ind] + order = 512 + dtype = float - data_3D = np.atleast_3d(data_3D.copy()) - data_3D -= data_3D.mean(axis=(-2, -1))[:, np.newaxis, np.newaxis] + data_2d_padded = padding_2d(data_2d, order, dtype) + data_3d_padded = padding_3d(data_3d, order, dtype) - assert np.array_equal(data_3D[ind], data_2D[0]) + assert np.array_equal(data_3d_padded[ind], data_2d_padded) diff --git a/tests/test_fourier_cupy3D.py b/tests/test_fourier_cupy3D.py index 39bfeeb..7c733d8 100644 --- a/tests/test_fourier_cupy3D.py +++ b/tests/test_fourier_cupy3D.py @@ -24,3 +24,43 @@ def test_fft_correct_3d(): rtol=0, atol=1e-8 ) + + +def test_fft_correct_3d_subt_mean(): + subtract_mean = True + padding = False + image_3d = np.arange(1000).reshape(10, 10, 10) + ind = 1 + image_2d = image_3d.copy()[ind] + + ff_cp3d_subt_mean = fourier.FFTFilterCupy3D( + image_3d, subtract_mean=subtract_mean, padding=padding) + ff_np2d_subt_mean = fourier.FFTFilterNumpy( + image_2d, subtract_mean=subtract_mean, padding=padding) + + assert np.allclose( + ff_cp3d_subt_mean.origin[ind], + ff_np2d_subt_mean.origin, + rtol=0, + atol=1e-8 + ) + + +def test_fft_correct_3d_subt_mean_pad(): + subtract_mean = True + padding = True + image_3d = np.arange(1000).reshape(10, 10, 10) + ind = 1 + image_2d = image_3d.copy()[ind] + + ff_cp3d_subt_mean = fourier.FFTFilterCupy3D( + image_3d, subtract_mean=subtract_mean, padding=padding) + ff_np2d_subt_mean = fourier.FFTFilterNumpy( + image_2d, subtract_mean=subtract_mean, padding=padding) + + assert np.allclose( + ff_cp3d_subt_mean.origin_padded[ind], + ff_np2d_subt_mean.origin_padded, + rtol=0, + atol=1e-8 + ) From 5054745323d5547a6a58cb4197ef6179646dcfe6 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Thu, 7 Nov 2024 14:34:55 +0100 Subject: [PATCH 11/31] tests: remove use of matplotlib from tests --- tests/test_oah_from_qpimage.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_oah_from_qpimage.py b/tests/test_oah_from_qpimage.py index bd5e604..1264617 100644 --- a/tests/test_oah_from_qpimage.py +++ b/tests/test_oah_from_qpimage.py @@ -1,7 +1,6 @@ """These are tests from qpimage""" import numpy as np import pytest -import matplotlib.pyplot as plt import qpretrieve from qpretrieve.interfere import if_oah @@ -271,12 +270,13 @@ def test_get_field_cupy3d(): assert not np.all(res1[0] == res2) - fig, axes = plt.subplots(3, 1) - ax1, ax2, ax3 = axes - ax1.imshow(np.abs(res1[0])) - ax2.imshow(np.abs(res2)) - ax3.imshow(np.abs(res2)-np.abs(res1[0])) - plt.show() + # import matplotlib.pyplot as plt + # fig, axes = plt.subplots(3, 1) + # ax1, ax2, ax3 = axes + # ax1.imshow(np.abs(res1[0])) + # ax2.imshow(np.abs(res2)) + # ax3.imshow(np.abs(res2)-np.abs(res1[0])) + # plt.show() def test_get_field_compare_FFTFilters(): From 531904342909fddaa5f6a9199759cb4de24d3c61 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Thu, 7 Nov 2024 16:03:00 +0100 Subject: [PATCH 12/31] docs: create speed comparison example for Cupy 3d --- docs/sec_examples.rst | 2 ++ examples/fft_cupy3d_speed.png | Bin 0 -> 47741 bytes examples/fft_cupy3d_speed.py | 64 ++++++++++++++++++++++++++++++++++ examples/fft_options.png | Bin 33723 -> 51334 bytes examples/fft_options.py | 54 ++++++++++++++++++++++------ qpretrieve/fourier/base.py | 3 ++ 6 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 examples/fft_cupy3d_speed.png create mode 100644 examples/fft_cupy3d_speed.py diff --git a/docs/sec_examples.rst b/docs/sec_examples.rst index ec01f65..4265126 100644 --- a/docs/sec_examples.rst +++ b/docs/sec_examples.rst @@ -14,3 +14,5 @@ Examples .. fancy_include:: fourier_scale.py .. fancy_include:: fft_options.py + +.. fancy_include:: fft_cupy3d_speed.py diff --git a/examples/fft_cupy3d_speed.png b/examples/fft_cupy3d_speed.png new file mode 100644 index 0000000000000000000000000000000000000000..d18c5a9d454ea31de059f43dca768dcb3672b77d GIT binary patch literal 47741 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⪙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 fj%rwx7hhe!%h0Xg*U?=@eD^;9MG!nl literal 0 HcmV?d00001 diff --git a/examples/fft_cupy3d_speed.py b/examples/fft_cupy3d_speed.py new file mode 100644 index 0000000..27b5a76 --- /dev/null +++ b/examples/fft_cupy3d_speed.py @@ -0,0 +1,64 @@ +"""Fourier Transform speeds for the Cupy 3D interface + +This example visualizes the normalised speed for different 3D image stacks for +the `FFTFilterCupy3D` FFT Filter + +- Optimum stack size for 256 images (incl. padding) is bewteen 128 and 256. + +""" +import time +import matplotlib.pylab as plt +import numpy as np +import qpretrieve + +# load the experimental data +edata = np.load("./data/hologram_cell.npz") + +n_transforms_list = [8, 16, 32, 64, 128, 256, 512] +subtract_mean = True +padding = True +fft_interface = qpretrieve.fourier.FFTFilterCupy3D + +results = {} +for n_transforms in n_transforms_list: + print(f"Running {n_transforms} transforms...") + + data_2d = edata["data"].copy() + data_2d_bg = edata["bg_data"].copy() + data_3d = np.repeat( + edata["data"].copy()[np.newaxis, ...], + repeats=n_transforms, axis=0) + data_3d_bg = np.repeat( + edata["bg_data"].copy()[np.newaxis, ...], + repeats=n_transforms, axis=0) + assert data_3d.shape == data_3d_bg.shape == (n_transforms, + edata["data"].shape[0], + edata["data"].shape[1]) + + t0 = time.time() + + holo = qpretrieve.OffAxisHologram(data=data_3d, + fft_interface=fft_interface, + subtract_mean=subtract_mean, padding=padding) + holo.run_pipeline(filter_name="disk", filter_size=1 / 2) + bg = qpretrieve.OffAxisHologram(data=data_3d_bg) + bg.process_like(holo) + + t1 = time.time() + results[n_transforms] = t1 - t0 + +speed_norm = [v / k for k, v in results.items()] + +fig, axes = plt.subplots(1, 1, figsize=(8, 5)) +ax1 = axes + +ax1.bar(range(len(n_transforms_list)), height=speed_norm, color='darkmagenta') +ax1.set_xticks(range(len(n_transforms_list)), labels=n_transforms_list) +ax1.set_xlabel("Number of Transforms") +ax1.set_ylabel("Speed normalised by number of transforms (s)") +ax1.set_title(f"Normalised by number of transforms") + +plt.suptitle("Speed of CuPy 3D") +plt.tight_layout() +# plt.show() +plt.savefig("fft_cupy3d_speed.png", dpi=150) diff --git a/examples/fft_options.png b/examples/fft_options.png index 5493026926b2b78872d5e678d08353ade5690beb..730666db4f4141d6dbbda7709f6e39e4e545ec14 100644 GIT binary patch literal 51334 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‚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 XeOmlF=CiYM-?OLB(>*_R<&OUYR`90W literal 33723 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�jCL}n7CwR^*p6~Nf?Yh`#aj_dYE!l5Qw{!7AQh$a?fWg#zl6Q-i> z@ka)mhfPop>NUmI1LX{yViu#{YYqjFZWfkmzcuQ4f@Y2}qgE z`nER(WugKO1F}|DDyi3|o^UH=d^V?rLsBnaemq|md#wT)*8|!LgIEp?KVf-b&;y`1 zUNI$kPaF83SMJ3ImuCmceISxqgVRVu`TS}Lt!l#r6F55^T6UmC6yqRbIu=TWx~zN4 zi(84BlZ#99NkWG)?!rR=vJc5qy0P{%{bdj+Osg&f5?bl#!FTrSI6mOhX=l?AADy5rG@VHJ$0MEk;HMN~Y@B+Il%Iz^%Guslq&5v|KOG4gMnDr*{_fAOY18j`P$k)xh@@hYY0dLHI*TS`YrN2a7>N{Euo$q7irxQ?bAu zzzo%0B)2O7Yy$w?(TsH1LlJamA;K;OHtpPK<1m0g$mzfm+D?T;y6!Hya1jxn2b2Kj zwvFxIp9t}>I%qyv4?{4|5F6Kq3x~gaiCBJ!VWEY2w)1@Ft~0N`ZmTYpyBhF#EWe-VwY!OBx*#TL>rW#VOJn(OcJ_U?w3!i^7htFeF`{8 zXwsX#W$%ub%et~+kWtI?#T#c!)xOsdxqHH|iK%kiR3TPKpQza?S~<7`PUciz?g`E@*N`9t-rqs3*n%dgoFej((T)~!(0e1>00LF3JlOeuLAiu80OULZSZEh zXZgpEr*%FT@nCQ5-NB0px?d52ZYm0A&&{S;3eNu=#y@cDo9 byCtWEMzL1HPS2g&;LnvyQWulYYdHTOl?VHX diff --git a/examples/fft_options.py b/examples/fft_options.py index 6a2ebe6..17789b5 100644 --- a/examples/fft_options.py +++ b/examples/fft_options.py @@ -4,8 +4,8 @@ user for performing Fourier transforms. - PyFFTW is initially slow, but over many FFTs is very quick. -- CuPy using CUDA can be very fast, but is currently limited because we are - transferring one image at a time to the GPU. +- CuPy 3D should not be used for direct comparison here, + as it is not doing post-processing padding. """ import time @@ -19,14 +19,18 @@ # get the available fft interfaces interfaces_available = qpretrieve.fourier.get_available_interfaces() -n_transforms = 100 +n_transforms = 200 +subtract_mean = True +padding = True +print("Running single transform...") # one transform results_1 = {} for fft_interface in interfaces_available: t0 = time.time() holo = qpretrieve.OffAxisHologram(data=edata["data"], - fft_interface=fft_interface) + fft_interface=fft_interface, + subtract_mean=subtract_mean, padding=padding) holo.run_pipeline(filter_name="disk", filter_size=1 / 2) bg = qpretrieve.OffAxisHologram(data=edata["bg_data"]) bg.process_like(holo) @@ -34,16 +38,44 @@ results_1[fft_interface.__name__] = t1 - t0 num_interfaces = len(results_1) + # multiple transforms (should see speed increase for PyFFTW) +print(f"Running {n_transforms} transforms...") results = {} for fft_interface in interfaces_available: + + data_2d = edata["data"].copy() + data_2d_bg = edata["bg_data"].copy() + + data_3d = np.repeat( + edata["data"].copy()[np.newaxis, ...], + repeats=n_transforms, axis=0) + data_3d_bg = np.repeat( + edata["bg_data"].copy()[np.newaxis, ...], + repeats=n_transforms, axis=0) + assert data_3d.shape == data_3d_bg.shape == (n_transforms, + edata["data"].shape[0], + edata["data"].shape[1]) + t0 = time.time() - for _ in range(n_transforms): - holo = qpretrieve.OffAxisHologram(data=edata["data"], - fft_interface=fft_interface) + + if fft_interface.__name__ == "FFTFilterCupy3D": + holo = qpretrieve.OffAxisHologram(data=data_3d, + fft_interface=fft_interface, + subtract_mean=subtract_mean, padding=padding) holo.run_pipeline(filter_name="disk", filter_size=1 / 2) - bg = qpretrieve.OffAxisHologram(data=edata["bg_data"]) + bg = qpretrieve.OffAxisHologram(data=data_3d_bg) bg.process_like(holo) + else: + # 2d + for _ in range(n_transforms): + holo = qpretrieve.OffAxisHologram(data=data_2d, + fft_interface=fft_interface, + subtract_mean=subtract_mean, padding=padding) + holo.run_pipeline(filter_name="disk", filter_size=1 / 2) + bg = qpretrieve.OffAxisHologram(data=edata["bg_data"]) + bg.process_like(holo) + t1 = time.time() results[fft_interface.__name__] = t1 - t0 num_interfaces = len(results) @@ -66,8 +98,10 @@ ax2.set_xticks(range(num_interfaces), labels=labels, rotation=45) ax2.set_ylabel("Speed (s)") -ax2.set_title(f"{n_transforms} Transforms") +# todo: fix code, then this title +ax2.set_title(f"{n_transforms} Transforms\n(**Cupy comparison not valid)") plt.suptitle("Speed of FFT Interfaces") plt.tight_layout() -plt.show() +# plt.show() +plt.savefig("fft_options.png", dpi=150) diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index 1a531a2..eb7173b 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -260,6 +260,9 @@ def filter(self, filter_name: str, filter_size: float, fft_used = fft_used[cslice, cslice] field = self._ifft(np.fft.ifftshift(fft_used)) + if len(self.origin.shape) != 2: + # todo: this must be corrected + self.padding = False if self.padding: # revert padding sx, sy = self.origin.shape From 35975f3cd128dbda320f55025a861d887a8e6125 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Fri, 8 Nov 2024 09:15:36 +0100 Subject: [PATCH 13/31] test: reorg the cupy tests for clarity --- tests/conftest.py | 23 +++- .../{ => test_cupy_gpu}/test_fourier_cupy.py | 0 .../test_fourier_cupy3d.py} | 0 .../test_oah_from_qpimage_cupy.py | 54 ++++++++ tests/test_oah_from_qpimage.py | 117 +++++------------- .../{test_compare_2D_3D.py => test_utils.py} | 0 6 files changed, 108 insertions(+), 86 deletions(-) rename tests/{ => test_cupy_gpu}/test_fourier_cupy.py (100%) rename tests/{test_fourier_cupy3D.py => test_cupy_gpu/test_fourier_cupy3d.py} (100%) create mode 100644 tests/test_cupy_gpu/test_oah_from_qpimage_cupy.py rename tests/{test_compare_2D_3D.py => test_utils.py} (100%) diff --git a/tests/conftest.py b/tests/conftest.py index 759db0d..cdbc989 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,11 @@ import shutil import tempfile import time +import numpy as np -import qpretrieve +import pytest +import qpretrieve TMPDIR = tempfile.mkdtemp(prefix=time.strftime( "qpretrieve_test_%H.%M_")) @@ -22,3 +24,22 @@ def pytest_configure(config): # creating FFTW wisdom. Also, it makes the tests more reproducible # by sticking to simple numpy FFTs. qpretrieve.fourier.PREFERRED_INTERFACE = "FFTFilterNumpy" + + +@pytest.fixture +def hologram(size=64): + x = np.arange(size).reshape(-1, 1) - size / 2 + y = np.arange(size).reshape(1, -1) - size / 2 + + amp = np.linspace(.9, 1.1, size * size).reshape(size, size) + pha = np.linspace(0, 2, size * size).reshape(size, size) + + rad = x ** 2 + y ** 2 > (size / 3) ** 2 + pha[rad] = 0 + amp[rad] = 1 + + # frequencies must match pixel in Fourier space + kx = 2 * np.pi * -.3 + ky = 2 * np.pi * -.3 + image = (amp ** 2 + np.sin(kx * x + ky * y + pha) + 1) * 255 + return image diff --git a/tests/test_fourier_cupy.py b/tests/test_cupy_gpu/test_fourier_cupy.py similarity index 100% rename from tests/test_fourier_cupy.py rename to tests/test_cupy_gpu/test_fourier_cupy.py diff --git a/tests/test_fourier_cupy3D.py b/tests/test_cupy_gpu/test_fourier_cupy3d.py similarity index 100% rename from tests/test_fourier_cupy3D.py rename to tests/test_cupy_gpu/test_fourier_cupy3d.py diff --git a/tests/test_cupy_gpu/test_oah_from_qpimage_cupy.py b/tests/test_cupy_gpu/test_oah_from_qpimage_cupy.py new file mode 100644 index 0000000..8a8a1f2 --- /dev/null +++ b/tests/test_cupy_gpu/test_oah_from_qpimage_cupy.py @@ -0,0 +1,54 @@ +"""These are tests from qpimage for Cupy imported `FFTFilter`s""" +import numpy as np + +import qpretrieve +from qpretrieve.fourier import FFTFilterCupy3D, FFTFilterCupy, FFTFilterNumpy + + +def test_get_field_cupy3d(hologram): + data1 = hologram + data_rp = np.array([data1, data1, data1, data1, data1]) + + holo1 = qpretrieve.OffAxisHologram(data_rp, + fft_interface=FFTFilterCupy3D, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res1 = holo1.run_pipeline(**kwargs) + assert res1.shape == (5, 64, 64) + + holo1 = qpretrieve.OffAxisHologram(data1, + fft_interface=FFTFilterNumpy, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res2 = holo1.run_pipeline(**kwargs) + assert res2.shape == (64, 64) + + assert not np.all(res1[0] == res2) + + # import matplotlib.pyplot as plt + # fig, axes = plt.subplots(3, 1) + # ax1, ax2, ax3 = axes + # ax1.imshow(np.abs(res1[0])) + # ax2.imshow(np.abs(res2)) + # ax3.imshow(np.abs(res2)-np.abs(res1[0])) + # plt.show() + + +def test_get_field_compare_FFTFilters(hologram): + data1 = hologram + + holo1 = qpretrieve.OffAxisHologram(data1, + fft_interface=FFTFilterNumpy, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res1 = holo1.run_pipeline(**kwargs) + assert res1.shape == (64, 64) + + holo1 = qpretrieve.OffAxisHologram(data1, + fft_interface=FFTFilterCupy, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res2 = holo1.run_pipeline(**kwargs) + assert res2.shape == (64, 64) + + assert not np.all(res1 == res2) diff --git a/tests/test_oah_from_qpimage.py b/tests/test_oah_from_qpimage.py index 1264617..34a145b 100644 --- a/tests/test_oah_from_qpimage.py +++ b/tests/test_oah_from_qpimage.py @@ -4,26 +4,7 @@ import qpretrieve from qpretrieve.interfere import if_oah -from qpretrieve.fourier import FFTFilterCupy3D, FFTFilterCupy,\ - FFTFilterNumpy, FFTFilterScipy, FFTFilterPyFFTW - - -def hologram(size=64): - x = np.arange(size).reshape(-1, 1) - size / 2 - y = np.arange(size).reshape(1, -1) - size / 2 - - amp = np.linspace(.9, 1.1, size * size).reshape(size, size) - pha = np.linspace(0, 2, size * size).reshape(size, size) - - rad = x ** 2 + y ** 2 > (size / 3) ** 2 - pha[rad] = 0 - amp[rad] = 1 - - # frequencies must match pixel in Fourier space - kx = 2 * np.pi * -.3 - ky = 2 * np.pi * -.3 - image = (amp ** 2 + np.sin(kx * x + ky * y + pha) + 1) * 255 - return image +from qpretrieve.fourier import FFTFilterNumpy, FFTFilterScipy, FFTFilterPyFFTW def test_find_sideband(): @@ -46,16 +27,17 @@ def test_fourier2dpad(): assert fft2.shape == data.shape -def test_get_field_error_bad_filter_size(): - data = hologram() +def test_get_field_error_bad_filter_size(hologram): + data = hologram holo = qpretrieve.OffAxisHologram(data) with pytest.raises(ValueError, match="must be between 0 and 1"): holo.run_pipeline(filter_size=2) -def test_get_field_error_bad_filter_size_interpretation_frequency_index(): - data = hologram(size=64) +def test_get_field_error_bad_filter_size_interpretation_frequency_index( + hologram): + data = hologram holo = qpretrieve.OffAxisHologram(data) with pytest.raises(ValueError, @@ -64,8 +46,8 @@ def test_get_field_error_bad_filter_size_interpretation_frequency_index(): filter_size=64) -def test_get_field_error_invalid_interpretation(): - data = hologram() +def test_get_field_error_invalid_interpretation(hologram): + data = hologram holo = qpretrieve.OffAxisHologram(data) with pytest.raises(ValueError, @@ -73,8 +55,8 @@ def test_get_field_error_invalid_interpretation(): holo.run_pipeline(filter_size_interpretation="blequency") -def test_get_field_filter_names(): - data = hologram() +def test_get_field_filter_names(hologram): + data = hologram holo = qpretrieve.OffAxisHologram(data) kwargs = dict(sideband=+1, @@ -112,10 +94,10 @@ def test_get_field_filter_names(): assert False, "unknown filter accepted" -@pytest.mark.parametrize("size", [62, 63, 64]) -def test_get_field_interpretation_fourier_index(size): +@pytest.mark.parametrize("hologram", [62, 63, 64], indirect=["hologram"]) +def test_get_field_interpretation_fourier_index(hologram): """Filter size in Fourier space using Fourier index new in 0.7.0""" - data = hologram(size=size) + data = hologram holo = qpretrieve.OffAxisHologram(data) ft_data = holo.fft_origin @@ -136,10 +118,10 @@ def test_get_field_interpretation_fourier_index(size): assert np.all(res1 == res2) -@pytest.mark.parametrize("size", [62, 63, 64]) -def test_get_field_interpretation_fourier_index_control(size): +@pytest.mark.parametrize("hologram", [62, 63, 64], indirect=["hologram"]) +def test_get_field_interpretation_fourier_index_control(hologram): """Filter size in Fourier space using Fourier index new in 0.7.0""" - data = hologram(size=size) + data = hologram holo = qpretrieve.OffAxisHologram(data) ft_data = holo.fft_origin @@ -163,11 +145,12 @@ def test_get_field_interpretation_fourier_index_control(size): assert not np.all(res1 == res2) -@pytest.mark.parametrize("size", [62, 63, 64, 134, 135]) +@pytest.mark.parametrize("hologram", [62, 63, 64, 134, 135], + indirect=["hologram"]) @pytest.mark.parametrize("filter_size", [17, 17.01]) -def test_get_field_interpretation_fourier_index_mask_1(size, filter_size): +def test_get_field_interpretation_fourier_index_mask_1(hologram, filter_size): """Make sure filter size in Fourier space pixels is correct""" - data = hologram(size=size) + data = hologram holo = qpretrieve.OffAxisHologram(data) kwargs2 = dict(filter_name="disk", @@ -183,10 +166,11 @@ def test_get_field_interpretation_fourier_index_mask_1(size, filter_size): assert np.sum(np.sum(mask, axis=0) != 0) == 17 * 2 + 1 -@pytest.mark.parametrize("size", [62, 63, 64, 134, 135]) -def test_get_field_interpretation_fourier_index_mask_2(size): +@pytest.mark.parametrize("hologram", [62, 63, 64, 134, 135], + indirect=["hologram"]) +def test_get_field_interpretation_fourier_index_mask_2(hologram): """Filter size in Fourier space using Fourier index new in 0.7.0""" - data = hologram(size=size) + data = hologram holo = qpretrieve.OffAxisHologram(data) kwargs2 = dict(filter_name="disk", @@ -201,8 +185,8 @@ def test_get_field_interpretation_fourier_index_mask_2(size): assert np.sum(np.sum(mask, axis=0) != 0) == 17 * 2 - 1 -def test_get_field_int_copy(): - data = hologram() +def test_get_field_int_copy(hologram): + data = hologram data = np.array(data, dtype=int) kwargs = dict(filter_size=1 / 3) @@ -220,8 +204,8 @@ def test_get_field_int_copy(): assert np.all(res1 == res3) -def test_get_field_sideband(): - data = hologram() +def test_get_field_sideband(hologram): + data = hologram holo = qpretrieve.OffAxisHologram(data) holo.run_pipeline() invert_phase = holo.pipeline_kws["invert_phase"] @@ -234,8 +218,8 @@ def test_get_field_sideband(): assert np.all(res1 == res2) -def test_get_field_three_axes(): - data1 = hologram() +def test_get_field_three_axes(hologram): + data1 = hologram # create a copy with empty entry in third axis data2 = np.zeros((data1.shape[0], data1.shape[1], 2)) data2[:, :, 0] = data1 @@ -250,37 +234,8 @@ def test_get_field_three_axes(): assert np.all(res1 == res2) -def test_get_field_cupy3d(): - data1 = hologram() - data_rp = np.array([data1, data1, data1, data1, data1]) - - holo1 = qpretrieve.OffAxisHologram(data_rp, - fft_interface=FFTFilterCupy3D, - padding=False) - kwargs = dict(filter_name="disk", filter_size=1 / 3) - res1 = holo1.run_pipeline(**kwargs) - assert res1.shape == (5, 64, 64) - - holo1 = qpretrieve.OffAxisHologram(data1, - fft_interface=FFTFilterNumpy, - padding=False) - kwargs = dict(filter_name="disk", filter_size=1 / 3) - res2 = holo1.run_pipeline(**kwargs) - assert res2.shape == (64, 64) - - assert not np.all(res1[0] == res2) - - # import matplotlib.pyplot as plt - # fig, axes = plt.subplots(3, 1) - # ax1, ax2, ax3 = axes - # ax1.imshow(np.abs(res1[0])) - # ax2.imshow(np.abs(res2)) - # ax3.imshow(np.abs(res2)-np.abs(res1[0])) - # plt.show() - - -def test_get_field_compare_FFTFilters(): - data1 = hologram() +def test_get_field_compare_FFTFilters(hologram): + data1 = hologram holo1 = qpretrieve.OffAxisHologram(data1, fft_interface=FFTFilterNumpy, @@ -303,13 +258,5 @@ def test_get_field_compare_FFTFilters(): res3 = holo1.run_pipeline(**kwargs) assert res3.shape == (64, 64) - holo1 = qpretrieve.OffAxisHologram(data1, - fft_interface=FFTFilterCupy, - padding=False) - kwargs = dict(filter_name="disk", filter_size=1 / 3) - res4 = holo1.run_pipeline(**kwargs) - assert res4.shape == (64, 64) - assert not np.all(res1 == res2) assert not np.all(res2 == res3) - assert not np.all(res3 == res4) diff --git a/tests/test_compare_2D_3D.py b/tests/test_utils.py similarity index 100% rename from tests/test_compare_2D_3D.py rename to tests/test_utils.py From 23d367f735ea1ecbd8c12fef6bdfab57f91e446a Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Fri, 8 Nov 2024 09:15:53 +0100 Subject: [PATCH 14/31] ci: ignore cupy tests for now during the cicd pipeline --- .github/workflows/check.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 21b78b7..6dbe717 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -40,6 +40,8 @@ jobs: - name: Test with pytest run: | coverage run --source=qpretrieve -m pytest tests + # ignore the cupy imports, as we don't have a gpu-enabled pipeline setup + --ignore=tests/test_cupy_gpu - name: Lint with flake8 run: | flake8 . From b812c072e99382a3027bbbe5feb18dca8a813255 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Fri, 8 Nov 2024 09:29:02 +0100 Subject: [PATCH 15/31] docs: update figure labels; ci: correct github actions syntax --- .github/workflows/check.yml | 5 ++--- examples/fft_cupy3d_speed.png | Bin 47741 -> 37076 bytes examples/fft_cupy3d_speed.py | 19 ++++++++++--------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6dbe717..12b1bdb 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -39,9 +39,8 @@ jobs: pip freeze - name: Test with pytest run: | - coverage run --source=qpretrieve -m pytest tests - # ignore the cupy imports, as we don't have a gpu-enabled pipeline setup - --ignore=tests/test_cupy_gpu + # ignore the cupy imports, as we don't have a gpu-enabled pipeline setup + coverage run --source=qpretrieve -m pytest tests --ignore=tests/test_cupy_gpu - name: Lint with flake8 run: | flake8 . diff --git a/examples/fft_cupy3d_speed.png b/examples/fft_cupy3d_speed.png index d18c5a9d454ea31de059f43dca768dcb3672b77d..9f0752392a54c52517ef2fbf6092c9b9c42a3b09 100644 GIT binary patch literal 37076 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⪙$CB#$j?btz|RV0idG5VQXJ{dOt00OHtuJif2Cs;a6g)79h!H5t`; zzs(~Fb)yGPMA&$Lt}akS)pgFjL$B89p2|q98_#kXc;H-&OJtsB!HMIEN zuFzXjlWN&+Bg`k#SC?ctd+Ok+%03YCNP0Gx;=MJG10bO3cP%X~`~K_^QyfZHfDK-4 z7ln5?sY@+x(ly6)Tsk>8eeU$vB9hw<3F_v!|2D?u^F-E#4u`9A$slNaEeS)o7M17Wi!^yICrkt5F@ z=+Nv9MJDmK<{@X`MTVy?j#J>~l@!Vv$+-d)ilvQeoQVC$H&3)0wI+D7&3gG&V;`}{ z=_yZQb424GCyoUlc8WcsYNcJ)*w~oCkUz@G*{sUl{@3aq1LjWboapg2K6G}WV_%bt zbez?FdM&^pZ^eGqI5U;HLk>MWUS3|jJUnW(OG+|4;&pR94`eyJdB*10_I-RCdO)HD z@tv3Y+i$-)ow>b)k{9DcSxWKNe3Y4)$sW&|dl`u0L%oz)NKlYAtKvqS%7G*sO9})KYmTC2b$Tg4#B-h&q$SmhF2WM_3n2C zyPd@$<>cffCu~WR@ZP<98!L2lbZD_!f87fN6x(#ry6yGSva8wI*`h~+L@u6Su;VR_ zz6^guFpy&QpS~~PqIgkIE&hnxNS~iQul+m4Wu#_`6h_V=FV%v2r|B$RX=WycStoUDM;wIvr4)C#76<8U&&H(CJ9 zJws4bpba5|x4aRrRd{}Olf=YO6)g-wqv!HU$->kMtK5ljk}4YSv+WCYJx6(KB^95a zRW>#4DdPydg-k7r(9^)M2xK#J9zT9;*5c|csLl$}F2N01Oi1qB`O)XYomc@KDeJZ) zRMl9ntTC!Dg_6hk88}TZ6wvcIqG%-0aKK*Un@^uUrH@xuRwmnZoh1cp)$?-?ctp*Z zXJRx{%N+z(u3X9fRIj*ntw8*OpBAp%tb~Pd_}q51zOk_@?Ux9EZ{*cSJSI3>J&mbx zY*~(O**EcKwL;lnUT=s}i3xD*ZSaekD&Y1KaB_0mBA+Zjo$v%5PI(W4oW zup!GO$)Pn|E|8p7QYp2xv;aO1Ew8W>S*$hxro&`H1sRe$R01<)ac{ z6BekMH4)_0LLy-@>N6lJ^;DGgW+-L|+yc7YMs@v^P@sgWhs7nef4>TeOBZiRgk#b9 zfnJ`;`c;z3>{|+|#shyNy6z|0(I?DY_w3oTKB=jx`Uz@@7FMX+Z3a4uHeUQ`!2&MY zbwKS6r0&>KWbNp&!waf}jOZOo$Kd_KQd<;d&;gg;1!QcWWdl))} zzgW)A&26^9&CPB4@1mp47&!n%rEf=o$@hy(NW@rx7|>74%-le`j!WYj{7{zE=L;y+ zSL=kbzFub`Qht6}S=m?;ZZ$za-$!d)aF4^Mm1j*zTAm$>@@0jA$tkOaIW=z6|W}n8c)yor}^_aY1aW}J041z z>7GCTMs8C95Exzbo{)CKDJnJnUBdP=4{!ITH8(fkKC=*ea>H5QZCfLG&&okgGJ;1?1|yVvCap&_JF)DZf?=a z(JEYfjjn4XU%F!^<@@UCffi@!TjB$MN=|*Vi_y+hDY(V03Z4cprO*@02^b>V%;7r1 zTQ?AEfXGFkwSpR9*!8=xVPj)s3FbtZ@)QMXp6mRI8dCdw{%lQ-j|RiszdTqXI?=Es ziWG8(9qAi4TK0KeL_uKRmNy4gryOv%vXat06m0CM)K%L~sI3bX=EGhZ>$rPvpZiy9 zMB3AU8S=-E{{iIK+0hY(BO7T_72d;)2}BCJfAPj%Yz{&7#N#+b!5_+lq6-U8sSB)a z23~R|ib}?H8EwrkzihR#vU>IUb<5EE-gyg_o%i2km~7d62!QV#$=_O9 zkHD&M{QUD{+~mil-E5$z_5m5!+u~a4X7$r@pySw|^&6@;(goK2UnvHsQJBO1xK79Y{t$p|vyy8vh zzs;ZdW8ewz|Nl?_@2x?epwPdW)cGyO(PM1Z4jeVli7{=bRsgM{3kjEoKhU&Eh+Cbz9#HEli8)txq~ht4=Xf^=!2~&oooZ z_4G(V0rN0=4yEoFfX^cd10y4^Pc0f5&2J83K?20=!ohbcfjJX9e7K~xR&}M+w|KBX4~%oM#)vuO zVVIbh;JgQrGmrJe56xPm*DX9JD_i;UuhodYf_^*Yk8$v;RIffK{sf_ll}4Z$e_Ym6 z)M|xzh$sR{DK-fNr^Px2w)m-x@4icKeigN7P~zZIx_$TVT|C<7q-p>J^3z{mJCV_m znl_AuCZt*9EZ>%tl*Fe(kJv63@DaGY5+Nn1s}8VI9-FO`#p1_ya&d8q#NCYnaylRx znK9X@P$O`SeCml|$kNqWE_P)PMcRFCiU@;PA$fyvBk36#QD^-3kO0iEYz_$RMG`Yi zQ0r)GdkmPTZP$cqUu$IRAW%h8&Ptm7&`0^@>vtcSoSd|Ebi9w46zn->5+v@jr*;x- z*S%L4(m#w@O$D`UOsqp@0n&&D3LsPg@<$}dUqQ`~{+#i%pciP^hKSEK3=7Q|o$QPw z1#YO@z>S^r^z_7Ms~kDv9qjVK-(9z{wDimbQX%11iXR9I@7S@!Pf$|@Rc@=O*TUGz zi7fkW+Ur}~aR`r*21T?3$?@*UFu@+f>dJ9C+DL2U`w8Ph@*{|{QoBidg>S8{SM!*T zR`!Gj^3~W#zc3Ynwwx)&+RIl`v+~gHWzrzbQZ!O6W2V#npGPlUiXinCp}kRefR`gG zN?&@{=Kt`kX2nrbI4Oi-MXDn_4c^N7dm(ozdt_J1m8I+Ud46*iwQN!Y^rGR>ySuv+ zYFc8jbZ>q6bNa>NL*-b2E|+h#do4_k}##|&N2$++TTE-%cv*x^d9T4_gS@OjlrosprRjy zS{N9FZ7I5KK-bRPUoe@Lc5oZW--g$KyPt=K_F*j@8Vm1ivy0hg)}pH8^6FB3;xj-+ zYwO*BA|W=7I3D z2Va$#ZK+-ml+p`aSSq5cpg?;I5WkVF)7&5I)+Yk^VK-C;w2B9BWnrA4woF)9IKRBS z{ITeJO}NG$yoKI*Vax>2Y5JnPv76SapOk%IL_~-M+a@To}8k$K0u)~ zx6CGHvc2F|YR(GiR`o3f5SU&7KPTrbL_t+wE)5{m4uYUiWU{8K9s-j=Z+l-~aIe!# zRMTMWPR6oBq`X=#fofy8PHMJFuaqzd3lIPBF_4{eF6dBAgnXzE+hb&CsFdB5d*5a7 z11Ii|DTbio-)oH8`r_2&MEw}*%j%uFIa%+pV@~a9YHGTU^RJn^#5VSxux^WMFz(cl z1zN$SJ$s}jiBab21(m~W=u>@F!w!~{2`D2bLp9eEdS2o1Vc{Z37K|^z@*gx5RRX!a zzP*h=(Z#llJO0-%Ob;yIYivK;AqmfKX{&zz`F8I}c}Yo0JPHlV%rSRQ@RH=)H$dg` z;mLpdR-~d`3bCr!w-Yvr{cwqEticU-Z)z(S!Uc1L-BU4$I+Amp zkk)Q+k46NwkEuJe`|XjZt`7|jZFI;&LK15Uq*q)qn;2O^=`#Sr^1kq;m12gvh&g4V z%P?1GFh=LAse9##iHUKNqU{bOx%EE!FJHb~4}cjzOxQi_n^Nz}~_M zvFU3PYsZoF(f`)UEfE|Xd;*8-I(S+xWSbL^7ZYqdWkb#D6H%*x;Szy9YP8S1?oC~y zp)Yy+=z!tHmMj;h)R>N2Z^}jo^X812_2SO)UqBNZEln#Ak+O0JHO!PUm-_ZOf1`)h zzC4oDw-jAMeU@NYvUc0vy;ZxH<)4z16J5bAX?_NK;2{{Eo1>`pFP_>1IqCx5xRU9L zz;go`q!|GC+Loe^$ayz&xIMo>@Fn_s8?W7fjvG@~4!bkor6@ zu*Y+9)OeeqU>Uxr4q5#uof z&R;GtCV`%rBjLbbuOXMl*y(227kvd^eFMrasy+O?^I#5r1SMVb=PzGG)(UA|(#mnK z-+i6`s4P=$l)!z_KTx9ht=hcV68pfwz+gQn_C0(`5qelrl^9d7sU06f9G0!#erD$z z_bC(#bn_5jm!g>+0=n-a!YZ>hA0$zGb)+Jbp`Bqb0{OxiTS_ez*q<|Zax7Z3qZcAT z?4XuMIWS(MT9VO?J9qBr8yFdJ;dmzvuUxjQ)aI->yIPUi`Wz`;UHZ|`ko`73zT&Ug zkT(!!c?%wm2C;CvC$PFPT@53xURKLRw{6{e9mwp>?btusQJa;$&3)V@%S>={b>#v! z+;>b`dU<}@fQzJ4pBAfY^!66%>v?%cw(;=1tjlzY(_gPDxfD$0MKiUL8dY7x=q>m@ zu7;J|l1&F)2ErV4QD1#Q66GYS4Pj2Sbmvjh6p8+J?UmO&f&AUq`z$a)p?AW82A_9d%P>2U^t%b|Ez3 zV5M)g0C`|M(LUl@79u58k#m4c&rq8)2~llsEm(jB(lq&4EwOfl#dL-kq6?ev8dL#| zR6vCdNK7t1`+W4*gV$s*-S*D8&vOx2R&00&Rv)w(f;2dpVn#nyJZ3ciePP|;P%By4 zYx1~6X@y`_r;g4ZYa;li>rhADp5#`qNT9zFJgq-pjdmTxSXUi54J(fd9!{JokDL1e z+f(|cApIxyW{!#T<9Wxb4t;9PZ5%@$oH}D?1^FBX-%K3Iwz&xRoYt|2Y-TDo}cC$tv7bh z9*1<%u7he_4?0||LHx_h)i}g>koAm;zylSx4XPvLw^fYJp@@-^HP0xE&D2axO|6A) zvCpZmX)fFUfK@P0QANc$!r`QpfwSk)R)5d~dbu=34r^Kst<1fPRiE^n+{ndofMBZ$ zPl%CxSe0N4s5Xrwa`m|;Ym2y?v8Yd?LRkJC%nD9}QvFY?C83Qli*Rpd~BpZW7H0>`zHTHdY>D*#jQv zTAsxWVOEwaVH4lIyTeM83)m-*m9a;(2SKyHcK@csj^Ji)4A6TuKmgIZSj0~93$JX) zr7EmGQO;;fzHutw1ESgI{JCvIUq5^@aBURsUS`pfC05~hdBO5xwnPjRXFk&4d;PtwAHi^sDZC301)MZZpr|)0NdRLmG*}kl?5(b8Y*grQ6MQ-zP8_T{rKc6koT{usT}p3we8GCrSd;Z3e(Si+ot$F!jJc zWOQo611QU%s6`JSt{1gx(F$=N_zR`7TExl(ig)95H64BiwJPeIF<=FvI(0NoBAC$( zmPZV{@9KIGGvU?g=y23h7TAyFxNzZBmg!)tlsh6h9SdQGV@?QVkbC?mPo5;D6;y?a z;Q|Hy3Ev5D3V7m&abT$+<5gk00P=o@2V{yJl`gqTn6=9mB~^9q)MQ|nD#9j@;nJ6} zBiR|8wE^ut9#azB&<3LMHT;5ta!E#IiI!5XLlNw#kihi!G8qYjq46D}BQ=BT!pc&5 z_zTTKf`cuWivyj*s|1>jd#v_QT;hi^{!4-%`)w>^CD+9|EK~35swp_HHE_5kER2Ux z@2(J)Q;_dBNqK5Px&$4<@Qgip=#Xs2a98n-N2`hOyV<2<)w5^Mx})QAD(9^f{}tst zQR2v7iC)jrnLoM_GPgV!#Ke>-;k{CWRaI3<7C_I{P3d;ZK!$`=#FJLWX7A|ie0FuS z>nRfN6Rw6*^n9mJ%PVjQ_zgcExnS|pfLqSOXVj-ObhS`m#9^}&wlP4;D+f3(4(1YQ zlEgbzzIHnYGq(~6*%aqz3|V8JY4uJ*)Dlqz@T0}KeL0F)i2@4+z(`scVyo6`zm$D( zqoQuZpN>V=oKaWR_n2VUGHVa9#zCQKSfxn6D?Id^$D}^daQ>mV?Pni7WU0NHa5HCo zyE%e@T8(ExDpMc?U2xN~RO(wu5Jaj4tJC1>pscSu(GD62xzz-bkW0!li|{01rhA(+ zRf+V2_=U0|`B>1P>za{tTOxPB^Jn*=^zGeql99x&0C54QkV!}p7(hD5ZY`o%?;t*i zx<`=pf%#8^pDh|$`H1c%Q_;`JpidmIyK+LFWNwEE|8%=97-)3x7|WUA@yzUEIgk%* zSOZKUT3++@ts({<)cVAL15{q<$RBj;)~&ECt9Kbt5hZh_ikGzCVwl~qVdpfJUq#Iv zh4SOtpMSmqK8~hx2Pcr>1($iff;@&IWu5E!@>U#453I|vB1GYLG z_oGoMcmx|DjjuOXW@=nSu~r+epN}(ryM6f0({JW?!Vri*68Z}_4Kj~FGs3~Zr1*w} zaggo0wa)WLODmRi$68wXqP!YHvLSTgA@{HGXCEDWg7~b8{KDiJPn< zUfB&$=4aoz@^6R>8Y9Lr8wSBmYmL;Y_t$)BFW^|U>W*0;9*-%EZnYA_UY#k0BQ@in zko!EcCB(&v7Kott(2eP;hLF-z^}Ir^)^QuIipNk&b&|+SMt*j3^IdvbwQ*M24lrK6 z%_x5ZeC?Z)C%(_JsV?JY3)W-M`~f?&?(xxiERi+$>7z?orV;c(-na@scq=x+R(1XZ z-y-1S6suMp)TDR7a4pME>#JbUEAE(OpCois3&YqqWYpygn@x(@eHUIXq81N6*+#K` zzLjjHFsSCmfCM13n9mn|_~11)9umf{>gbNskj^M_eXotv9Gm{x$|ij*(kxy3#_ij; z9|N$h>v;s6?6SbK((gIjFx}PW8)7guW57NRhwEE_9nbddJt#w&A&2ch>Mg%8;Ck-) z8ULpp9Ub3R{>lw#1=5~nYkrPOP0*=vf^^uGzTS;#@kj2&aA5cu|E=90)>-9pF6Y{J z+um?_V$<(Yu=uF0^B1<%b^mguJm~O)i{rTPWWB%VQi&Haskh`%98rRRm2~XBJ=Jh3 zM(7!Xse1oYK_yRp}>6 z9S^E3)}aHn8GNDYNk%o#v1x)`t6zKG5eN=)I1iOrMtzoVFv>3L!Y(5&x)xVf z%(2CP5mqzF_#A}wJXiA{c9xBjgpRQJx6WtWm+{nsvyc_GX|;)5z$L_ zFT;^Cg|bL4-RM`7aKK}Sk8h4n`w-RR64OzKl1v1?#O%n!XK+wnc=ER8rBP25~CdF@JUfRR!Fi4>b$2|+d&M;OBI-EZU}gU;P0A)!r}W7^;re)Dwa zFHccZcLi>a18GQ$-B=6y-*iIzPnZgcQVVNsJs00P2FfyG_k8l?N&VbF)G6Fu?A?&obVSi z_ai&`fR=>DNpC!HbE7|NqxuBoQR_0-dGqEir@n@VYrxzthHH8L?ycLl#aQ4UNxhm` z&I#|ptvBl;nS>%HgXMvgtCnJ_9NeyJ)|54@ovlsNM-k-g=C%QB5z~TDM&MNJHl8#! zjS{hL%QK%qt-gGQs}pJ*Mq)K!1)@|Y#S3%7(a~}GvhWe7_DqiCPH~=-90ghF4CBn8 zd(0q>EeV%za~dl)6SN~j4v?OHP}PNm^`t0pqGRzJ8lID5qFqvhO5AJ{OC#1{1$;G4 z*7o+LjyKcN($>?iBf4z>TVG~(lir#NDo?yisE`$0+h0=Cg*t1(0K8d1T}O zT%jRmt-pH)Yn?_#6JzIxPsL-|H+SB=8`xK^4dIV-x4{y40`M^@+fj8&h!kkGbg|ru zeffps592(!lRPWpJU0ohJ_oje~gt**nog1!g;=zU- z9!wD<4=4g$sF~}_rwKI_Vzu;Dl)imhkQn3D0ZeiBQU@%$;9CZOVu7`hC!p}YKqMFj z;=O~FoM4QYsizjvo83PjwWy0fiemge$nwgnDpL3pX9t{R=W$z09VB&ibpRMq=(r(hnf)pZ4P-3bhc0s(cY+0e(g1gSav zHu0Vbjl}{*5^89_t5>h$b|KD0F3Z#9z~2=!ZG+Xc6IbjRX}zUH7Z?C)C`C@OwG%1 z6NJN#%ffbL0_Ip=GRKo4cLUs?>Uom~7YenU7_uEd)je6Kj zA~^=fIs-usaG^R}Zi5GwY6*%Z|5g)9JjdGnogKy>U^Z!5;5pj4j@?sp8VCafgi=C` z7m=-*$m^8Cni(nmLNk%lDX)BqQL0Uf_^CnlmEgI;FeThanuz+GSG>~3|9T59TE2X` zL*P|GfoAOnS8OCD*eu@*Ldc*@E9ujU3Kj@fp=eYv#7f8RiH=)Kq;$&9zG<*z;g$D# zMxDnW&AFgcPQU&qe+qzj>mAq*7btN3{GWbWD@@6S0wkV>NUTI z`_S+HeU&D|>EG}^tfiq5fS>sqn8)U2yfV~w0UXM?5c|{gUpEzjJ5)5dXgB}Ab|L@O z3;zGk?~$dYoJlz&*!Ro%MJKqn`DRY9OrFIx{{Ll-RvSx$cf*QKWc2zZW1#vSh~cCt z24kuOF|59G`r%-!`2V5@+E$b7et4DO$@wDQh=4%!KH>?jiPx9jD?78Vyl?PVQp|ue zi0le|Vtf8>(q#ZrGa4KVDjgN*N^7{e7z$Py_{%NlVuz_hyRyqj{08Jc* zf6aV(yp7CSJ<>BmTseS2`}FcC62o;DO(C{D3JMzhbQfp}j+}1N(ttJqj$eLx3hZI> z<^3xe6PMjg2;j<3PwAGapqNF<{@m{j&!cI2_M=q@M;v)f(m5 zuTPu5rk+C$E~{H8IV{t1A>iq_cJmU+@roM!1mtBYD&shxS@wsjjL3@FpUB~&wd0UE zfq}BqN@jlQeM3Yrj|3}H>h`wgN;M8cC@n@J0SloDKPvm(Rqg;~wD^&i<(%;OYQQ)N zfPjL2kf<^QQF~5}w@%r^KgZWLP+dZd)`0g6#rsUCz1ZIE}QOk$?#|tk69% z=qTvIa7J+=jS?1#9qx@F)m#ma2+a4h(oeLKj2} zs`52lTv1Cn_#(_|W3OGiRteNKgxI+gw@Q%c8#PFZ?;Wn1f)gQK1u5t+Lf((U10d8V zx|$wCoxB8OXUe44-`L1C{r!2HUM2I#&z(D$`1e^A#AE;%*a!K~yjMskYbTog0)wsK z2VAmzxiZ*L`>&t%k&n-z_o!y3VpWGh;BnqX!ab5^EET%i6C^~Ahzf90 zz-gai(KsCb0nX05@7vpzfn(7gw6kCm50^?qGn%{NUOfmO4urmVtw{6Y7mn;jB!9 zzkP_)OpV>RX_H(C_w=W=IbP84=B4)a_2IsCgU1oO0^0At*kFVFL#2g;=*EsGF;J1T z4rwtCN=?5dulHp&r9^-LEgpDSw$@HSb)|)Y0M;~`#F6jFkl4EQ80wVl@+C8iV!26% zv49Ep-x9+|*p_RH56;|4nkWZi0RWx~lqZ5H9Qv9FOq|~r2y4zO-*qgQRNn&4_}#f< zqits_;jkNWL+u1wd1zsXn>w{=uf(Rn(%3c!*ZPrcj904f*37wPgeO7tL9HbOjfF%P z(kg|KtsBDV@s;SKd!b z8g!^=2D3p`P*YPQ-+@)1IFl*nO9ZHJv@lN#XNMs+ zP3HlT3x9x)+75vVtrI$c@rS%7vmT+l5D@alMll5tAXR{W>=#Rgo6bA)QUUmAfRxZo zl5yHMj;}*cBDJUTSxMPO9m4#RUKB!z;k=4?cup^(cO&)A0yR~$Mn(*XiKnOqNpJ*R zLfoe4X@d9?fs@O^K~;v#Xyqz|tC@~-L#WI(Yah|VAj$BHi)*3gGp!8!%V8Xhz*Ahd zY3W0e0}{DB%ef`BXoRsOEfyIQ0H-zZHz~zvh&mvuoF`BZue2bJdci+NZKnGMHSgSz za{Q!*+mQ`CV5I^KSOc60*h+{cK1~K4vDrUn0wpOr3%0y^`Lbg@3xSl0&laSjgYqs9 ze)bU-js(W&r=*H%wB4~PzyiL3(}n~W(gKIdz7`*l>^b3zVx|ZC;=yI z;KP_(*eM?>LgJ7-BOxn{!K#sLT(Ozh)6o^CinFkk?L2#ORM$caeJ?ckKrC`Fjo58D z?064YMj4vgJn%lYyK=pH6Yum&U6YqLT9evK8b8P@O=8+cia+Ir6IqqK{>Oh3TjMl` zmh3)iOE_A8R+Zp^+ z9$7`*aQ1s%`>)j#)Ji*#(2U-bD-Mm|#7LwX9UC)6$%B;PKRG#Wi+t7H#%b)mG&@J;6g$guJBaqGVfKWM8lWxm59jc~R&b&=8|4imw7EG|D z4~by`S06F=K`h{*BC!%o1OA}w-`N~~1Zgm}E>CDZxl^be!v#~sUXz)YcP(?H9 zKEvWQ1opv&^*BY2c#y1GV^P-zDt|}gk4)w;@z`Twt=cInstl&7RQydYh=~rDtYF!(M76w{Z4{f0)csId$q!u$LB0`{|!i(&UiT z2_*=JNez^GV`l}=I;xCGhI`Xo#2^W0t`R>Jhpgo_@WRL}3wXc#XqyZWosJqP$&HSusJAn$<*FS-3 zXNn#9?`1L5OS$y+n6%F_LXZ%AOqfM@T@#Z^B&3%vTC_WQ-^_>9?XH?j2iAa<7_u&4 z04!y4#Yr)*E)eMT!%YKmsK7Bm@1A+5b>MEnJXjQi)D>rU^8O1ATl>&<@&;~>(tugy zow^ubfTZ06G3&=?1i_k&9r)oHk+ye@T+i&YA(9r%doy2@wj2+SP>>`{oIigXR+$JO z=%jFX)$z&`EKPp?bBjZ*b}{;?p)!CP62|@`vP0d`CwKU^+}p^vIcix85LPu^fG7q^S>%IEyU$)ERMQEvi0v98_E9)R)> zd!XY=#mTHjNwNk44_qK7FaKOck~wtq6j!$C578z-(jxp&7JM{0Z9fD({$0DMBwVCwqin#C1%qQU zGmDePfrmq=C~)wY*ibP|+%nATw}Q6*;rd9QC240swFoM=v(5`mMx@^hZj*;7n2c0r z;a9xP3M09Fz;Y9iB*##H4Izg{<3OQ^q4AJTZNTnYU^```6YPrD^u(Df(QC(Xz?2KH zWL_qCKpKe#e<4eTgv#M;uwniwhWPixFY}P>sR3pfAwI>XozRoT8QJK`Al_B~*;>18 z?RkUgc!CRs6m)`0Lrnh_n2^+7*kjx2Ga>)TZ)@te_RT`47%9k01l$Nl{pYmsB~r*V z5@64I2B(j_Qq}iUqim4+vr4pz96X{-;zCGWssqIvNn_rdjDCwZLfj8wfiR!V&Dsb~Od>3(P-pHUZ9WObXxIU8x(%zoLnGw-XktPK z4pc<0gTEjGjx==mm;UgyNOL`;7|3oF85Z~^a+GioNDDuiQ-XX!j=;at-C8}swVk_n z?`~wEoG-y>3{dv>P&s3}g~OCFBPad`K4^IR(P&%Yd}UQcMn;a|ifVv|kzF)XRb#c( zLFw&@o{b1~2dm}^psx#|K+{VHCJ}T?BDK+Cv~m4VT7Rtmkzh~W-1ru}W-^Qd*#pJ= zD9Ldg%ovWKX7A+Y=KgTeB+|jcIR=`zCPwoBXbD}?S4BFiEt0HSB@xSrwf-OA)1}8e z4mFG8`yhqBVO`(oP@Q zvfkvnK;I1C@hW2H-X&F*TV~U~#vHqVPUqB*_xWz@`*)r&r}Jf3*r zvEfKn?ZOaQCcW183gJ-6i*{yNJShgCU6H{sq-+B#qJ(-UDSRe(=Lr@kQ&_M`&``3H z9u5?rb##;2z3RKyV$}$QaQ>Y;|NM7eECFAG3i5~K!XzVG&@oA7#{4@k&SB0rPe?$m z#(UDK!bxce+XmMz$ZKK6hwF+)_t`87SVVeK)SQwNvp-q zw0d7Svp{_t0oIr8-JY1l@2p~!pmq;ctK#6ZurM->g-{-391^KmWMoWmSO|E4jS_Lt zZ|)6LzG5Whlg=HWjp}3*Qi_~|%%O-yPKGFprafa8Y>P$&3gGdOJA_d-8sZR97BICz z1<4iuG2Vq&1KoZ$AbW#~_h&}hT&q$lseUgs=ZapKNMhRQ>8o86`m}VXrI1AW3fM)B zXj&D11Jjz~Nc+ab_=xbm_L*awrtR-I|GvKv=5_!= z?Sl6M6HqWqL!L2#1b7|^nILDhu4viOVPPVj2WVj?%nmV&jVmZBl35-=q_|mG;1QSz z5qiN)DPbxU`kQXI_au?sOjtdPd5FdkrcdEF{rKbc_|xW2Z|gpD8GreQj0N}o;gkr& zT0$NXJ&B?j?FBR*Oa?gEngCm8ER0#ebK5oXH!x44c=iPymkcR}<#_UB!QAFO>X?$_ z-Z--nn!2wE<`K6E_8MA%6SQ(IU&GJJONFbV+`)@E{Q2|et(+vH!SSJt#lo?o#gH)r zWDH6%#!(T6bIx}bp!2ND{88ZqCfPZ2B8{7p?vFWJ}{&4PXERK!O7} zQL-TkQ;QZPT_7^@ukg|z5Um9skLSv$w$(ySDwd&>%q#Y~Xf8 z|4S$a$;cDjD&P$;h72fgQPVe%e;QT(e7dG5V?j>q%#Z@jK|@R{+RDj~o;%XmMZOA6 z(kNav8qWY=!xBn@yC}3L(!DePPY2% zj50`dU^c3tox3aALF_q2gNx@5Ym(lJxFWNM2|{ZPn!4Tk(hInBw0i-a?(;wZ-O>sh zEqV3XqcTCTC>|ya%whb0GiyMh+nQ(U5U|K|1@7MrDKj^ez`h1_`gzp3+iE4%8-=yf zZuigf7fy!&=Docstkn}dkk;wjN0s#)*Rw#)&u>4t@&^Ab<<;iAqy?;U>H;E_B!oHjx zU)0gt8w13qfFM>2dn0zL!FnG^ZFY)F(~LRM*Npf(EiuDmMuHv?GogS882~R&DRUB4 z)FJziw{hNVud|`{5W+q8+kh`Pkx1fBn9ZsQSjA)yKR-@-Z8yR@_Nfb!UJwR29L$Jb7x^jFP!H!@A9v*>y1N9z`heN zAxy6WT8DdF;YuJZKB4n?{||)T-7De@n*BV6ArL;&1HLR2yGf8SFqn{LHV%F{;D_Q9 z$ZY1plhPkXJ&eZaxMaNEM_}#J@47FK|9U-ezLK?hvjjleTAVzf_yE!G8rL>L6M$t$ z6Z(h7J_v23l3L6$v-jFm^fbJc(`^mO*%)PP!1Nx{g!4ny#_6*qfA^B6SxV_P(Ag4F zq(d7*{F_-Gy=~X*SB*e@G#DR+k}SqB#HgiO;vvJRBA}(`J4a#OzGDCF>pWkZ07pgOs55S{7bya|Mj0B z4-&HoX$y{eH?xM;S9c~-_zxab>(3gz$?k#>Cg;EBSw#4(TyIhQBw_(WO#rMz@?;jo zB*dx6fBrVAFg6e3D)Y{^lh;h0XD+8{3;1|CkdPUYFdQa9#5PYQV}zjI-JY)Eta3)f zBW9IbENbCgIwC6>sYz5lRENA&K&KiwSqFRnVVrRBWS$<9CXwQZ(Gp#Mpc;GpRCRX! zSnFlj@+t%9#H9W_2ElUY&WQhRCABAx&#v9$$H?okj_4M^`92M;@*WRTSQLs+U|7DJ zwzE4VAE2KMi$VDFz?1}_7zKt0z@IA&@k1!#OHl2XelOAU@sR*s4j>^-K^V}&a7WQ_ zC!-)DR}PFrvLbjMDw%o$lhi*1RK36GThwH6#D9YggYxMC#`i>m9O*&9udJr_kentm z9}FKn)^>U24@H<6^csSf?+d6uMQH7;(?w?=ndU%rFwd{=7XH9hkir0C{~*N_i8rQY zW|B>S#?XICF4PK`llC|)EX=);xM==1zU|5L-D>u{8{0;(n+RB95(LopgKstJclLXxV5v-EPzlzSj8n^dL?FcCo}BT%y+4qh;M<6Zko2D;_yVjb@-qz9 zKeD2vSDMUFBqIpb6=y`icx1%Y-+-%S(CLEwW3 zo0+~vI3kkB%@`Ghg}WnXgJazugk%Kw;tL56f5;~w#P^%=%^vv^XEa~VCY!WX1lgsa z+~}=KAR2-q1PQn()HfQ={Bqk`y&euV@^E0l+zyV3%>Ti}vA5Xa7R^(r(n=Z|HQ@u^ zm-L;9AoMLh5K~eW$hbgK(2s*5NO2*bcRw|6~_jssePjb zh%-X3$LQv^{IEwo$9oSDrVG?bmQf}`bx%jp-4APKS(88!4*7*vHiVc(Oj zv$wtkdz=uEXtZ)~B(w$G2-{)#B`sO5P%|LvMj&7iB66QeCC}c|Gn>i#hTU$r7jU?! z5rj`>)4*DeEJRGJ6DclR8X`%tb2l8?@;N=LNXTsjf-0Un9MJWWE9K2MC& zycW7~=9lVrE6$?>mzkieo=n(TmS4!Iz%&oo97*Z0_nl}Y0YTD{XGzkQY_6hWgGqz7dDGx(q& zv;&Kh=Bj_*^{F%fUG;{9A;m1%plw++9ynr=k`+mbgc9V3u*-rnnt}ajU}Y6zyaC)A zX(A$&%s;9ff+ZdcOL1WwrsJFdIr-!MA%p?0<`STR3qw8K_8C#sFgN$@KfCmr`Kl2- zcEj;>piE-FBaH}=PW>%JW`|P)W9yc@BaUOVm2`t&MdWo4F8dIDZ9mcCmkOYYiY>!?TIEfG=jyPToq7fcn1)@iQmx-;btJ50Cpv5R?(&Ree6J&!I23|M?ivbaHp-8Bn zIyqB_TaRa0%%g+WBSt6qY2Tn$z=IaIXwD$hjjE7oK$CgTy@iT)H?gJ@jfj*?_#JW{ zNnt?V;{NsXMi0OP@>L&EZBLJu#vE_dDg-c*x`!Crt*rWAh#O2F6z@g_7|DVkKsPl* z-Foe>zvN9#P5Wfj1;!BMES+ycD~Q%gx5d;=66#1~u<$}8ShIO^Ea)J3)Er7U2r6Nmc6TcxC{HA8 z1?hbi4Vdhpv-2W8Yj2|(fnlMl(`15iJhG6x2h~ehXRoe!F zX6->|ks+YAKEW8QH7+2({s*p~t(+`WFSM}u_}#?vi%c^;ixdh9h8mxY%N`COekfQ! zho}G?LM~uPLJ}=OOJ51P`HV!?o`~K1Jm# zOU&Lp-7%>xeC4w&kLBW7$rcPh(7LV8Zs9 z#mb8*O{NfolWR}pbI=bs6=5J*)~GvI{XpF)kkPQcx@3wo@z;;utO4_ZG3aFDRbogZ zX{wz*DP;aBTvwnm?mA=wv`?GWG4_>+!zAQbl;V7zrlCA~ip80pJaa{(e_nvY%Ll;BZ4Xh5j8%@ZsF=!sHmP3y5*> z<=ae8%?&@L``tnSYyye+9y*7wiDwn>`M+x#!t~1XnAk&%=Hzi80$_ODc(PN!4)Gtt z(_ZoRIn-3rF+iNBU{g;Kj}=%jLL!rq?4R@IVZM&@olV~@b1$HAkD>g6U&u&q$GUav z$c#P0KOyH2VcaDVMiBuO5#_MX3T+9bYZv*@1T#`IFjb$3=3u9@KfhQ?+71Bs?0QE~ zp0S`I$|F+{J13qJvHZJ&zyPAyiafK9`EK@EF^FblbcSG{Z18KSkS+!PwlY@tyGp?S zLDsVA|3oDq4jW8yPzD`H`gw^gv%S-3W^=>;0rv&0SiDrk6=GO{cc>FI;Ni?ioB?nl zV4BiDMWBDipW`)z?MMFf7nPX#2upb!Uqn$OxfxXI-T0@8*{o%Wa^Uyx^3(pZWX(kf z%qskrf}p)^M)C^>l!dzMNDRTQAD4u^sRmIw@a2R+4E0nV{ST=`{0qtB@dajbt;}Ya~(N+(7XzZyqbR& z2gmFY%_C#a2xX3E*V(v#+qV2auFZU}9t(khRJ|BvBugVD3_?-_rfC_;Vc;)b-kQWe zgcv}IZqj)2 z=y!Sj|G`{EBisiNXNH+Eh8Ty8QxQ4l zRL;XVEXNWlA*19_a%gf+%nUPQa$3kCgJKXVq!JSik3+~=NpcoSj6=;sV!yA}@(g?b z_V4xEd;eaq?e+Ze46D{!-_PfBfA0Ibuj{%WW%=|qC2*J*^UOPS;J3dXC2uUWdK3DkFUU@5?hC47WH1fggUR{j(4a$8c?6di~yctM>i0pAOGI z%2{ot0HI%=OZvGIL6Iai^x##!E6tPTYzs4%IOkKl_$}qtFI0BQ75W% zoMJn)4c%R^#24LaXxbtcts^d%jA}%73NOH^OPBAgQ<{AOO-nXW;tNv4{RUQjt8oR> zM?12am-CT2U~EI*;yYNKj(oiBpld^@GT>QmB^myTz;uD+L{wPI!K+O_YU@+Zr{5H+ zBncX&T-X!*W;S3dnU$LRf)}6#M~&yxJ9ohLNLL^N%yB;EpWj(qafPa$dvqI#CY>?G zdjV$D8uc?W?#`~gibwS5K%svz-ED;#mZ|Q=vI6c%pdi{QrsfF3oi@w{LmoV0#E8!a zn8MLf{oucA)2w}Q z=s6p3=fu9Pem`x|Dp}?jz;|2dt6~E_{yN)2@OSFE4jwcQo4ton5b|>*O#%KFsX{C- zZYdL;hoo*0mnFbqgf1Y8G@lhFW|u{E_ObZqqT7*XrlUR&cvusens$#oPb7;6XnyDP zosRk$L#A!CLb9t3A5|5IOpg&WjWzFboJs{cJoRTH zv;%pd2;AQ-DmFBq&mGAmPUkln#C1V(x&aq=tHMtY7kegSAP9Rd<^8hMF(CWSh*dzkBJS5hN#wtK8rzKx|T^BQeXfXb}Bt03}c>DG(C$GG@UI84Prr3gr zs7y|TsWbnd-MIpq0fJ+zv@?IPAxlfUky<9Y9+g51|Hh&AYu_LjUHb>bgnh88~%i%an1Z&zp}q_5-rcGx5Oy z+$#0dcqLRsyak97swkGzsS=Z&&}?;cg*6CNkajYE^&A89_kkxJP+Us}2Qos1@p@%3urEGb>0TVgE27k#RlwW6JQ&|qN_KNS+w*ZbfYf?&T!V$$a zs!7yP06)j__@clJj$>%7zq(M zc}AF|!vJ_rG!-b^{SJ6Z#*82AjZ`bsJ&{}^<)VczdA7*hqRG*-^5Edc!A2XCC9ebh zev0XR4-)J3U#A;IU>t2E^tjLVP$vqx5u!V%#0O>DOWZ0OQ+X@FRX?xSP!&K(AIOiK zo_g*Dc2@Vfxzi|Lf?|I(CA1TdNv4M9ohXQeTfMx~n^%}t98cR!Wv0}Tu)Q*DO)i9E z8D3i(*3W(!c{M9*MEq)o$8;n*R;%!z9u+QD-2+ZA~A}RNhmaz6X%Z7W>?BU69QS(-+;#; z+@&UC5H=I---|_YXas;rr44L|tz&%9dqkM?LkolX4r+!Gyz4RZs=9-Tu{)<-m??2m zEY^8V_q&I*8F)17;!xypq1Nwclz#bEL?4JlL%??i(~^DK)QqV*!aHP-L1*uDeF{m9 zMq))K(j}{Bc>3(wNzrN;wN$5uFPN*UXFZ(YbEa>8d_~vSearR$2V(Wk&B|ox!iLUY zrN<40Eo8)>=I5b*?N3ZJ7$L4N@Qcz;h2pSXR|Ij$GU`v?t$f<(Of!Kc>`N0klI#k5 zshFw`>J4!rJHtoTU^JhAx<)`&se^-+=ujLY7_tAZ7;c36T&d&+jY@k=&|T8A)9o~^ zDDXO|>mK^+Qt?2EqlEld-_#ZP7ytO5!HgvG7D;hL_ZmI=-gjTj{uHVgCR)P>`#wemh*p8g-rj{B6GgYY*|p<@Su15e2B!IA!BQVN1|MoH;hQgAzD zGC$7S+b#GSnB?3=ru*SWzfpV}qrQj0KytkUuQ{)f+!`>U24F7dZ20$TA=~CUwi9?-BheOU1FCLVtlOPjzgNE915O*2=vxe~dE-cqhcTg|WOO}I?( zU;NWgXMJOmHe<2YuvV?kN{UF{11>yUSfd@p;vMI?BnIJ$BKasq_7!wQCqf%uJy!g!SSftrUC+F`at@>G$V6QcwsV0i2bz$uBTz^7?Zv@vBUt9g8S z3hZQ!xe{-M?4TZ?Z&79zh90ZtwzHDis(xyG=EzUggUQ~TGnRTz$S=*n4R~w#BC~`Z_D8Gr*UVT@JE}HU z>In^pC~^dy0}>TrBKPjx0`8L>7^UwN*Yq=XjqC>9|Ln_WXsk6MrC1@fa@{&<+}^p6 z8z)QL#BLCeM_d!lH6YC+6E^P1q84T>rSE&G2L1^$E0X4ezKBp`T9mHzyLXIddWp&q zGNr43r=V7+)@&tPES^(Ddk4mCKXTM__(QGTQS8QykPymE>fb-{Z)UJ_$B6TdTpGI| zd$;^<>Cxk`Keg~qAq(s`Hp{Er#k33FO&rPN;C3ks&~OtYEg#Pt3>~@xl{T7XtaPUZ22#7@YwwFL;MZ~*qC0s zcLi@R6ukQIJz1vS%@Q;Aki26nEDH9}(~Do3UVL>A@1**2U{@2Oa--a#zvmhJ%@gT{ zGAZsMEL!N>&TIkh+qk|^SNcZ3Eu)T;9Kd%I;3#S6=<0`?!R(PGoBq^F`KN_1XFzNq z*7eJo51R{*VauLlju2*8;WPyvP+qDmhc5Jf>x?YG6$y^Wqn!4wBzRKhU&H#@FaY~g zr9r^VT~YkCc?XE9Jn1^aU`E~G%Bx2PVW5HaX{`t;M^XhzAE$IeJoqb!#*EOYXd}XIVM+8 zz-13Kw2M2pxRZ{+(WR7TF(0k=TBXJvwr8SmqoIPgDGh^cf?+$E{Idt*dJ-(%+8 zT6qL8ANE8b*UjGryc7un`JM0aBXX(b(+MWeL10(6G9MTJ4>o6=Nk_k`4zZvJrG9Wx z)w?d1h~+ZE=v~;g)+Ho`ku65Q_MAEO%fW-<-UPo2DqpHph0y~`O>%8nDbBwBPOB4Z zPY&2WZgcBZqc*4Itr>fzRrTM;4#_$Z9@aI~E41(MKYq!!JdLAhKc@{2b0R*9x?Z!$ z;rjhKbq;0EiMe-s(wCpP`{u=U%w1b_*{Wl7(v56mk+hsi5I&8Scpbo+BUO7->IZqXfmVuHYp7AGh^Xb z2rL<74kjC_=pATu7>ps!(T2U6r-K92aifY1Dg!NENNaQn%eSZ8WDY{MM0= z?M(l5=f_bqizRQQjdAaNAPryz?(t}4&T4yobh3gQ^34C`lMC>oac=AAnDO2xUxO_I zvF2MqeqSgkuAiDAz>%kSbGVQY!`+grfMzBDktm_yxRo0}zIE7ZtS*LBGRx@Jt{PP) z>ZhMRDm=KbIaGBQC^U=oqHfOSQ;@+b(2(6V&ersume=BWQtRy~9?s3*+59vr%+Muv1jm$Y_Gp!vFOUr7 z!}!Tv*yh?{%=5BFT82-kUL+^W70--d!n zGbN|=9A4y3JD^x<&gjBQW?*^kTArUKBG;;QV_%+DgyAM1*D<|q=Sd^nORE3MC;Z)i zKk^T(j?aAelj-X_S+Q3nAy5FNf8$QqqFnybD#G(c5S8U%SidNS$t&O1)!i1MFeBo9cowr|INlw$Y_dNJ)b@vpPAXMM#-|t zZY|=sb}jx8mf?p^IlG}K=H-xvh7-px(&KtA7~Ln<@X{-Pg*B=5*ObO}%%{wv9b5H$ z%`)5I)QBAp>4%N8%$iHEFBC%;K)@B|)3Bk+&X#jm*tBf9+}jN3KmF(KzHg!NS375w zcBRb#uZ~M7hE=$8`KV9q1K5tc9PCZc$e}K81{y344!vjV2X9*Y0~VPBz4xw0b6a83 zAdxvCZ^rX;Jw(}qdEwToz3CS$XuvNJHyvHIh=$xx+vl6YMd#+DGwMA8d7>v!BgK4V zua@Wa*3qUPUQ(02KEGUzZ5$8{eQITG`ukoMya+Vm0?;wTD4P*lJfAiA!Ss)vE1zsV zDaK{XqT(U0GW)xmom~%^S>j%Y?6ZEY!Fj83}l1qFm^;@z*-2Gt%wZPCM%w(*a- z)E!jlE_4Fnl?PvV_6fW^Yif))n+qE@oRE2~Nltkui=%6Hu|@UiHhfgcXVIbEmE2sz z7;~&oL9S;JXJPyBDm`!9{*}r%1 zRxICCH^IA?)q&`j`ZfZsb(avJ+`j5xwfNa=_`2azJBdC9LcjhMgALiup(&5O)CR?` zl0R{g*powJW9Z02GdYPt9BVh29N=LfT0_!Q!LvO3{rD#> zIn5ujG6Xs_h-XcTDPymDnrr_XS9*Du^yM|iO=zI3a$`^Y^)SP0tf%o?szo)^i@6|M zyo$fCh5slU`?-Oy{x!*9Ja{m8QNH_KFyPodO$};xqA-#VCP3!h2PXQ#9FNt$&L28e zp>Vf00IH_!x`rvQQ9}vGdMYAQZQIE3`JjJcr<3~@KabJOcKT-5ojgr%c*OAY(7AU* zn4LIPVOweq^65anW~H|`#E~v~NnT!Kg9yos@+nTCFLS)l4~AhYf`aOL+KA`dJ^no) zYtoyB2{m2?c&s)ZaHj#wk5_Ulb{JKa=(c{)mrS_7A2jU1EO&92h?*Ezj#l(&(!}QJ zcvGopYdJ5zq%ZyU7ah6q$AYoF=y(A3ws8~5;2nMmT*`zm#(4rD2%c5`uPj&OL@>*k zPNsX);&Mhv^UclLo*utqX{ygM>!QHRomOW0wFWo2GxH(3ceaGPMu`Kq0f@!=<%?bb zhexxrx04R1Ka>YDDFHB`}mprw|4DZqKfa)%7Gv0xzDLHaFyeNF8fzDMtpNKAMod7m5uEl z?d>XmPsvt@<73g~?gNY(6>^S-Y#g{uShf9N#p-QY8yK#z=P_$5*D^lvMQ(4ulcc8$TnT31?qkyd_b6R>l?PXM~0v*_dkx4WH}|3~4Nv*JEL|--0pgQs#yLc*=#_{kbj)IlN6#u|+Mn ztRxg_A3??$3l5%f1psk~Ohuqdb@kL2RX723(I=KUh{mVWLYEe5jb^VFNW{&m5W|?( z4%{(0QVh`%PAOVG^5wkh9~7!Ez4_aB7p1Mz6)h=$>>8t$J{_^ zJQK0fWRK&rxetQj;B|!(tWeW#23Q+Q=vB{>m>U6T>B^R!;x;U>;GuirL5Axx^SXw; z_ebWdZ%(f5o(?--=fr3FyKuk(t0R2lt5Y%YsW(DIbo<;D!wR>Fl^>eQo`m?3i65qU z;$<_AOecMO0(em3z9I3=J~5reey4Yra;3%_-?$Er zCqvs7JzM|gBDn`cMZGoUR6(jzl`6IdJ$L1E?2zLyoawvl8}yAik4zztR0~!5PplJi zsp5p9aUWV|4E7cxsF|$STbL_0w|yA{se|GGL!cbnqSC@#-MC5niUmeR8&^Nh^wBL+ zZk2KkRa<)g{K`kpn*P$(r%~oVi(;aOCPomPIWCn374Lq=)c>PV{}DlC3Uo*k@tC}W zaumkfm0X#OVV89uF}kZ6vA3u{%Dto=U=fg9nr4GjZAv*&keV5bf4QdTou^H)qpT_U zw%{pt*s0WBwx;y3(#c@uZ;-gx`^!Siha@(nhLxBLh)2cHOoq^m!4^#|_V-roW#;wQo7G)HU^fu5 zMc1J2-RFzP#|0~Q2G@C}P-{_tH0`V&lu}AVagaKv{pv93&^r&bw=5I3Qs+z#Oe#3=gr7(wJU3oo#=A~)*zwXGy{>`_ zcrlp&+R~q!PU!}RosH(|D$$fGhPE?=g#=v-!Oigg#~|6VqdBNjjPF?^=ghC|wr~^V z@cgR5v-x`$4V&3@Tgi(DsV`8{P`xE8chrq(l)6`LSJhklo#iGF{X`6kclQ*wXClty zx7UObDPjd0H>u%HM#QyxLH>x)ian~D=^#{Nb~~-9T*kr%lu@QA^E97D>4$3##8B+s z_=%Zdab(VKWuh8!zy@%VFGrd69qEIt%S3TQGxgl1IOr&`!g`8?tLcJ?re(hI&qjSE z?GfQyofWd6-Irj5D{qhr`OfXzUhi!Q4Q(JsnTArL>gOT57)2wODvRd9dE#mQ!8b+f z;KemGyZSgZKe?`p0|kyuLXm}CNtm!+-w3|0nDo+G(KHsHlNj9}TEL$7;sa^@O2#XS zdMpug_yJwD5M6}g$&mWJ9fSW`cQR*9z@0q)HbbdF=&*}PdGAG>sNQrzll=Fq~sxe%am(;7N`=NA97nOCu-X18_8B3Mc&Q?S=uzAq}~|IPefN+a7zVttWHm z@d;U*+32#Ykf{e$z>n?BwV`F#dB9d@f?sc>Mv?E_l&$28*WWTG$r|mC-lsDq!$1)c z@!2}`_W2y?6K=CpzBIJJM6M|pNTMLfn%8e3F6qFj4B=wTSIB~(Y8z#aqfVzRhm`cC z{UBPK&h(D<^xZTUfpgO7&5Qoh<3q_%i07)Sja@&-VcSkKLejap%a}4za8@O63!laB zQJ-D2jW8GW-o71#`O)vs`q2>C`TmcP+33udy9)=#xQU+Pu!O8BVO^8(Luo={?Pk9J zH>_%~r<5y4?Ky1=hviHJcWxclck0ziJr*5PKF+pp{N7iw%XLjA5MDlsXdw=ns$l>y zS3vtm(AxlehkRqcc!=OkmHF9$m1!6;rN0PC3-e+%R z*JGS3-5zTD6NFCq#HLhHsVq;?=;%dVgURuWb?=t0<`8e|!?v(7h~Ul`m<=38m7z)$ z`D|(lddzB8_gA@NRKJda)Z!xuM|J5h3;(jU7 zjhIj)b(e-Z5uq-ivLMllto}%bfW=K6l2evM^G)gdv$2OpA7c>Tea|;9SF-kXSZ*>9 z1KbK3W=j453at=U92C^pXyCHh7K{?mOD4cAX^)D|L|+8(L2j!xrd|!_lF-;? zd4@1fMi#-8r7kGp%N=77UvZs^8OOJnLa-Ao02_-FxEslHq1`k+-oUgCm-hq#z+8MY$wF@w;r`G+*X%TO*T)qqwt;fROOV7(CQ;<0W|F*L6=O%|*C=zmA z!3b;SbkY+IeP#uDqS4G1K-tN&ia+5Tk6&~1!z?=iDjOxyz2o-y?p+%LHaku9qF0SrMaR%L z1ap4t$+!a!4jWq(W2~=kya-)%Y`TlRThO(hYunkBPPe-A;k{3vet&(?@uty^#}`NJ z&9>a6L50)m5BbV=X_+XGpHqG8_AEQxuu`=_wmId?UP5zjZ2;S8a;s;r%BQQ}`Ql!= zr{6#O$BRFvfB)*%)vsnxyp~*bZ(?G`7kQR@c#J1zA! zhW*@Ugskq2V(SV@VnBF7mj`yatEihFu%vBTbu4~h$vdLIuD4U|N92+@zz15nu0{NZ zwSkVuL4Qx`w-s2Y0{d#G?0CfGHiU7Nl9-|lzdrm+TL&A%+*%>==l#|K=Kp(< zhWUq^17BX^5yfHnry`hJW3eoL==mI{=}U#Ne7o<@L4~FdQZ^*Dir0d+KOHh6qy>|b z8@u>F%(RXH790o6i|^@~v?;J#)ge-M8KS+zbG-hTtyqjP$dyAysfd8;zQx2$Saf@| zx3}ElWab1+R|hR9J~A>1p=5WieaZy+ZMf6m+}p@(8O)9`g96K~{ts>^TWguyLKzX3 z&1eISt{?}#z>Jn7hdWIeCMqq()kT0@VX9omKC#38+6y3)18MA%!fdAh{=U~R`v4Oc zI1kwDUfyxR6`0HVDft|cO{}1+C`uyv03b#i4~+nqOo+u7Uju44R-STW%~fgJr%#{H zQ%iQU;UZ^$58@`cOA1WK)(#mMNdNvvyQHoy8X|+ik0a&{^1n0neCkk8-oyyXAH9lC zBuExZ8gcbVH!7z+clwADc$4S>nzp0gdh=#&9)oMdHc2AS9&GovIN#Gd>A6d{EjS?<@hB0+3;k@!aQ zqrHs+*ibUF-eW0TJn; zH(8)+0y{@SX7_MVH-SnAbZCvcG|237P}b)2bNiMFsC#hP)0x8(N#-drWSIuvO)b1o zj5N*lAa8*8&`?CKwrdR$or4(Vp*iT*kpg#eQ-och_SJc$Hg$rBoOeu?PaGFlR424w zYW&!7KlX#n@H-x^JX_9Uu=wsU(}nMFf<3KH0NOKI7!WtY|E%{*$uGbJIM-Y8x1mik zWot0xfs3H(g=Yjwt_yxW{l_UVVF9;NCw)RgPR&zxl?=569Ku%Y3`Dn#d|o7dXsq9@ zsSxF3VY!vVv4%)i-Qa`1HX{dK#0Vn71nbq%8@_P1fpOq$}aG z37m;vhOJpM4#KW4&{Ha>dA#*%d?pWl@v*B3J#~pan=E!E{#Ie= z1CI8zr@n2vwoF8_26cs0X@1DcVPY+h>F{U3cN3R-Ga+&Pjz{_u##02631gz7ZX@6I z2N_znqX|{)*FAdN^DSrFmI}9-k;C3+-4#5Zu7u!B*J#L|pn<^W@?S5teg2I{!}&qhwmNx)0{(F*QvjHI2kC?C~q)KYJa1eoMKv3q*& z5vU+9_O#c)>UqxLdn0j}UFr=A7L*l_fWw~H)XLb0E?DVv;kMGPbT>lbJN;?RLXwE; zVW~b`jCsM|E=g{f5&c)Y2250oevUn3PkU;)Ne=olJ?)|NzF1WC^ljz>*&9Q&n3L}= zNNV4yOcYhgD(=WI?(zWI5nX<&-0f!fif+LOl2_)|+ZkUJB+0O(B%gNTZWn{f&IFCQ-0*49?Er=Wj;UzJdX3~b z0VHz4*TZM_yN^EGxqjoUHw_L9{iUtNhE0)!wuefh#jU8jO?~{NM?l4TGZn$m1HSlX zxotW16zR;LAls&f{1L*#MgfmBbH#;%c{KJ-w-AqXaP-amD&hJCMPs9FIH{nh%bp+W z*xEKx1Oc1+^|zCJBLloBT$ZsQ&h(*N33(4ctM)=z;=(G;yQsI!9Ft#aO}5l>&^~h) zpwB5R_G!Qm!?=l2PJJ1ndcNjoWjjAv8CP45cpSpVPF!^}1oH5ELn(?0LA$@BzCU`SQ(WW5H6*-d6{@M56T#wi6)E{rd7#n51n&^3P_|e8cB+P$xt;@6>xxKhgrZYu1#QHW& zNSOqqsrChj7>h4LD$SKXGiJ>As&9#IV_Z7Tl#*0v8`#?eP&4Y5fi!y_?^z;@Z#3G}^G_ z;kBep<_y|p9!Ug>&Bm?lc>K=|{Ku!i^!kQwZg=CPz(9fqyK=nx@-nQ(e%)7sL+jPl zK^|7E7M!P+UzWG8LX|3G0B4qL@mgH7lRsB0aY2>JlDgfTxfRwFRl9{bIJ?{n{&*i6 z`c)x<7A>9+U-QahN3t~PrskTz_p;91K;Ps2OWHL}Ujb;<0?(x%I}PdDrOPQ;@q^2U zZ~Q5U-3Aq+Dkm_OT);1IeNe&b=@en9;C+4J?d#XM1B^zp+n0J%``$h4nU;7zCubZP z&1i19ZvbCLfIy8#{2XHa3>wvtRvj#t;Cygo7A>1>67y{0L^ic@Hq^2L&df;3!+|JfAe}YgJ6c&P3iM%+x#_o zE&~Si0r?3i@+x6-21v{Q;QTw!Z!FQh+S49!+|HF$jfa;rQPrybv}PV272Fc$SMNLG z?%jPx+^fZ}*Gf@okL|av{aB;Xc#5>74+VM%6^5omemt(MZAnJOwB!;$=~D968QKed zFklU_*64S?KmU_d-8CWZz^SPI_aPqddjENf?%p-CCtfivU z;fjr&e_xVZvaH*;-|k@`MGJ7bGm{L@fr3U@ucjV8_)*07v59FlKSt9K|K`aHXn_tF zJ%68eZn#FeSDvzU;s6LO5(u-xt9i(GuVl+2$=C(yGj@W#}MhZkD!d^%%TRj|0F-mmjxf*mj7xR7<(ueA$It7V~~Q(CZx*0!I1 zzSKYdQO$tlpn02Fv(m<$<@RsoG2OMxLrS63Pxg{^9qwsQ%$qd((EN%gdd>H=b6l~$ zR2rT8JdeqL1||OMc7q=}mnh^h2Il7sk_?e_q+C9EEDi0)!Ea*QwzO;k`=lT^J<9z; z_<=sF?d2#VBhWv#?Y!ZwX|+K1G?>-WxHy_YnZ%fvGJvDUKtEu9{kBy8~edQ4Btx7+f#Q*e`eh5b{O zW+qN-A=4ZccU;YvID%W?0b06Ux2@KGUU}OQ#g=J>E>m7N-2RH%GW5s~w;~m&%a$EL z9xTQi*voV5s1JIxk_GTXhE z8#%{){r;6J2gg5!Z%3dQmbmVV#9;@WX5F3A{w%XIycYkxHN8Tx zk!WB*D4629GQX()7GF&ZC)m|~@Fv>R_8LfWrK(jMq&yjVd@-;I0)VeXo*-|4wHFrl zsA9i}t&fJ4>a~rcqHtZ^;H-P&xB`yiC*RW^4z}0l-98#UIjN{SUCKVBJqs5v?jQeY zzls$r_N6@(o!F^VuSMh>Yd}8IKo7m``uFbLg4T^a#n(|L?pk%4eRX&nMgr7L`PYjV zt0{N3cb&h?jVQg;8}xEm@zF$n9l?*PC7X9GtP4U?D}`_?K_XjSKSnO*Je7d zB1uMR&Fumi=<2r*cXsCjeSitlQf~!!G|kge3%Klk$a=E5n=?*ewvh_^;G0hCUtj6Z zi^t;dzSNsAvTO%tJU6==X@~7fId!Vh-jr}BY-3WQh9;ihLcm_3Fsa z>vq3SY@hmhkDnv{WTeIz!g9g-1QZ`$A?h2<%+Qb+(y)x+bzlt2{%AnN`DYE$nNz(* z3M{uH>a7uFg32T?CPp$4@gryehLfP)r$H-ERLU2s=x}8j2aa7MJOHWJ@e0AEWQXp- z%a7?eU8D}R+wxtVW|YU8>V`Q!0Z^i9%u!))HSt0T^^Lrnm#HUYUo_jN2)Lc82ETXgl#iE`$ecrxJAPgpf?oi zwO;~5Is*eq1Gs_Z7faJ3lCFf1Cx}PIv*xHvxe`uKnsnD??N(~3f0GkaoVAX|gB~H9-a+`vgf?ZWD?n-I`vY})Z$x$8caj7a?O48)a;MWXG z5F1s!zuAw3ILG|{bU{*I$=@J=!R3l1$8tPM)$PIQj#}cgpv$1;87($gd+ZuH?*VFu zNc!Q*kJtt+Xp{Jmk$0<6ZNXVetUSBtlV{Q+xqfpm6qgIw9Vxj(s@RuDnF`iq^=0FQ z#s@r=Y(54Xc~5hIjaFQ@I&W;CFf0Qc+YhSWzWu9x_uT#ORqM5mZ2K9V|J9_!tAJXo z0F+$7C^UA&AK$hA>yNTg#s}SJ*Uvf?c$bIULQ<@;=)a!)`DZh@&de8V>w`!s8(<`@ zU$r)ODF#z7OfO%tqE|?Bosw+s;g%}@xUbvj2Z)OGFpv42W>Gd~c-#`4Em#C_i#wd_|BIH$|F$V~)iNx#9{*z`D;a5%W32@W zkniB-;XT%GI>wl_X`?hOo$fEmTP{!%(uhUJSqSMte!F44PR1>23)#pXGSzTS%v+-SYAD z0d>^SK#>x}`UP@k8r@&=aTd2yK60=Vv~>8i0x5T`1=~-krk2=^&GdB>_wV;5#LYm- z0+%i^qFfmxN6f~co(c_{X;}pjiYD4txA*=@%tI#XIo>V1uevNep#$V-lnTl3FKr=+ zEa`Dos(t@8B#n{-2x0=dYLYYY&o@j37+@6Br_Q2(PXCj(g;V?DM_l?AgfJs90lj($ zn&^K5RgXIlM{h)~%0q(6hDXp>h@4;{8f?y&kK$L0~x?)idih>;CwcKUa-5fR!($xR^Qp&> z>XDcjsXd|B1!eu!OXlL?_18`7VkQZ5l*N%HU+i$~FJWLthQ@ebPrymCPJgdqXPBt? zuy75W*<9o}&XTj-x;4`CMIa_Lnku9?2&I75Ph*aU)Ws+f9Sg$Z>JNe#1O^hVJ{)BBYG!}w|NAe$OA`QUP3!U7h!K}3Bv`S)ML`(B{t7*!DehomRtk@U7-D1K zo)nuv5(KD1_ZPK$bUZ&6bDSOIc$-MgPV!ml5x9_hLub2_8{|iW)I)f7=eb+yk4rfM zNWeIkYYm@25vh<^rhcz1N2w#86Wwyy-{*KYwez`_0Jh^ad@t}uatYSCKboeXMMZGX z9srk~SAIz@OMcQ%n2EeFeh+LyXPS1`h3XT259f|j+CxgKsiK#-QicwWkij=;l#Ec> zESs6~a_2$lNhw9SGv%*BDXn!C_{}4~sLPIe|NOOhdXk!O2%KEUkb@jZEdh=ms)VMW zJ@&t*w2RLxfQ=VX6WlT5+k(`73L zJ|SAyIIK@JFiy{0PnlnT3FUHeTcDG?8WQ}oWah_~5H>5mJU6s!9seh#ENTr#405Ah zPkYJ}`=eE!QA)s&DZZPQVRk#A#rP{N>!$n;ttMkp9t!8B-eiwfFBg(m8T-(a+P&f+ z|JL2O)Vt%qZhSCvI?z`l43~x07q&Xry#RAZHFV4yB*Y=s7htUW6~W@oEYt*scrO;k z2aB(Tgr%PsEZAL)hZ0h%7_v#RS|S%I*asu!QmN9VivdcuL)%k5jAQw_jx!7V9!<#c zr0sm=Sal4_$o}@Ev!_mJ{yc5niioW9_WC9roZbqb1glQ_*}s|hPz#({1tjsKSDOjf z{NSSHICaF`v2F?|vE#*2`56o(u83f;X(!a}PxAA@2jGF#c*!F4h(Gg36(JwHgZ>e` zu0JWu;me|BbciM{T|W8Qm+i;EO7XK^%`x_FSFc&Kvpwwz_ANoQ1Mau*Z4c$;89+ql zNd^^+0O$AXpEc;R-A@FNsoI`=ys>G;fqKINETY zv~pBz#vYm>enr;x>ZzTft>e1xLSDRu#d2#?c%1R%$QK%7FaL;x9Ix<&7vOgOxH~E{ z9&k@ehf&+@25nnT7f$ejEFEuN1|p1vPhRdWX!^?K9mYNdU+QUOoWn9NKTy(T)`hQo z2fN0N8}#Fy!};C|vtm3C`WY5A3NFmNZZ&ex32yD}9KSn5)>95M;6sIaEO4t}qGo9UuC4iP(fKV}P|SBqYT5DEH%}>>(FvOV2Tm zhHNQ7ARijP`l~N;>TVg-B)A|w^T^X7gUjz8nTwB0(8lL$)e#Cmb@=ecX;FmsO+^KA4$u?$o#qN{Yz# zLEY15E^#P6CQNIF7~wIy4WI?;#>6Zy zO7q%KZ!r0|JMByFq+^tM4W3t;-KHT<`%qh!Ta&%T{Is!z#fFIv=@ZcXIE@^LMTRu+C4PSj;)pLxrH_8ZDGEDLCQ* zQIAP!*#0rt{1_;~FLEz!l^+P%(uvS!yALV*L6W%@hPhhx%38E_X^Xr(*6H=qyp*}1 zH1fRyW6=5Gwvk1Rr1J=->L5DQ1qk7y=@QW4F!h;)q|n;jLJL+y&-Cvo$Cr{~sPEx< zF8dP^(%2dg&h5f*uvCO)w{ zBT0D2;e6SxGIl%jeilj4R%reO%JqO9f6=)^59F!2Y&;g_mF9_Dg^itE1zpC#`r+A@>e9 z!UJ}L4kxMUCeC%G%WkDd(IC~TghOO{KEq_K2q=$u(pbAl?h8 ze_vw2;68mqBx0kg>W3*vN`NOv_{z?2z84u4uPwOR2L?c^(29_VtK2PJAz-$6=5)4Y z6(mP*kdCoH^5XpJ=KwbcZFLIo=~c>N!_Lcn`YoPq;tpA?EoDbpqd)(D00_NvzyD4| fj%rwx7hhe!%h0Xg*U?=@eD^;9MG!nl diff --git a/examples/fft_cupy3d_speed.py b/examples/fft_cupy3d_speed.py index 27b5a76..0538bea 100644 --- a/examples/fft_cupy3d_speed.py +++ b/examples/fft_cupy3d_speed.py @@ -1,9 +1,11 @@ """Fourier Transform speeds for the Cupy 3D interface -This example visualizes the normalised speed for different 3D image stacks for -the `FFTFilterCupy3D` FFT Filter +This example visualizes the speed for different batch sizes for +the `FFTFilterCupy3D` FFT Filter. The y-axis shows the speed of a single +FFT for the corresponding batch size. -- Optimum stack size for 256 images (incl. padding) is bewteen 128 and 256. +- Optimum batch size is between 64 and 256 for 256x256pix images (incl. padding). +- Here, batch size is the size of the 3D stack in z. """ import time @@ -54,11 +56,10 @@ ax1.bar(range(len(n_transforms_list)), height=speed_norm, color='darkmagenta') ax1.set_xticks(range(len(n_transforms_list)), labels=n_transforms_list) -ax1.set_xlabel("Number of Transforms") -ax1.set_ylabel("Speed normalised by number of transforms (s)") -ax1.set_title(f"Normalised by number of transforms") +ax1.set_xlabel("Fourier transform batch size") +ax1.set_ylabel("Speed / batch size (s)") -plt.suptitle("Speed of CuPy 3D") +plt.suptitle("Speed of FFT Interface CuPy3D") plt.tight_layout() -# plt.show() -plt.savefig("fft_cupy3d_speed.png", dpi=150) +plt.show() +# plt.savefig("fft_cupy3d_speed.png", dpi=150) From 236176920b64e78364349c13f1804387f9a7e743 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Fri, 8 Nov 2024 09:33:31 +0100 Subject: [PATCH 16/31] fix: correct define FFTFilters upon import --- qpretrieve/fourier/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/qpretrieve/fourier/__init__.py b/qpretrieve/fourier/__init__.py index 07de88f..f95699e 100644 --- a/qpretrieve/fourier/__init__.py +++ b/qpretrieve/fourier/__init__.py @@ -11,10 +11,14 @@ try: from .ff_cupy import FFTFilterCupy - from .ff_cupy3D import FFTFilterCupy3D except ImportError: FFTFilterCupy = None +try: + from .ff_cupy3D import FFTFilterCupy3D +except ImportError: + FFTFilterCupy3D = None + PREFERRED_INTERFACE = None From 32cf2ce08e78d6b60379610eddf57c33e33be400 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Fri, 8 Nov 2024 09:36:56 +0100 Subject: [PATCH 17/31] test: check ci tests to see if Pyfftw is the issue --- tests/test_oah_from_qpimage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_oah_from_qpimage.py b/tests/test_oah_from_qpimage.py index 34a145b..6f568fc 100644 --- a/tests/test_oah_from_qpimage.py +++ b/tests/test_oah_from_qpimage.py @@ -245,14 +245,14 @@ def test_get_field_compare_FFTFilters(hologram): assert res1.shape == (64, 64) holo1 = qpretrieve.OffAxisHologram(data1, - fft_interface=FFTFilterPyFFTW, + fft_interface=FFTFilterScipy, padding=False) kwargs = dict(filter_name="disk", filter_size=1 / 3) res2 = holo1.run_pipeline(**kwargs) assert res2.shape == (64, 64) holo1 = qpretrieve.OffAxisHologram(data1, - fft_interface=FFTFilterScipy, + fft_interface=FFTFilterPyFFTW, padding=False) kwargs = dict(filter_name="disk", filter_size=1 / 3) res3 = holo1.run_pipeline(**kwargs) From 59f67eceaef504619326e241d2e27b10d64900ec Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Fri, 8 Nov 2024 09:43:36 +0100 Subject: [PATCH 18/31] docs: lint examples --- examples/fft_cupy3d_speed.py | 5 +++-- examples/fft_options.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/examples/fft_cupy3d_speed.py b/examples/fft_cupy3d_speed.py index 0538bea..e4b14f1 100644 --- a/examples/fft_cupy3d_speed.py +++ b/examples/fft_cupy3d_speed.py @@ -4,7 +4,7 @@ the `FFTFilterCupy3D` FFT Filter. The y-axis shows the speed of a single FFT for the corresponding batch size. -- Optimum batch size is between 64 and 256 for 256x256pix images (incl. padding). +- Optimum batch size is between 64 and 256 for 256x256pix imgs (incl padding). - Here, batch size is the size of the 3D stack in z. """ @@ -41,7 +41,8 @@ holo = qpretrieve.OffAxisHologram(data=data_3d, fft_interface=fft_interface, - subtract_mean=subtract_mean, padding=padding) + subtract_mean=subtract_mean, + padding=padding) holo.run_pipeline(filter_name="disk", filter_size=1 / 2) bg = qpretrieve.OffAxisHologram(data=data_3d_bg) bg.process_like(holo) diff --git a/examples/fft_options.py b/examples/fft_options.py index 17789b5..5fc52e4 100644 --- a/examples/fft_options.py +++ b/examples/fft_options.py @@ -30,7 +30,8 @@ t0 = time.time() holo = qpretrieve.OffAxisHologram(data=edata["data"], fft_interface=fft_interface, - subtract_mean=subtract_mean, padding=padding) + subtract_mean=subtract_mean, + padding=padding) holo.run_pipeline(filter_name="disk", filter_size=1 / 2) bg = qpretrieve.OffAxisHologram(data=edata["bg_data"]) bg.process_like(holo) @@ -38,7 +39,6 @@ results_1[fft_interface.__name__] = t1 - t0 num_interfaces = len(results_1) - # multiple transforms (should see speed increase for PyFFTW) print(f"Running {n_transforms} transforms...") results = {} @@ -62,16 +62,18 @@ if fft_interface.__name__ == "FFTFilterCupy3D": holo = qpretrieve.OffAxisHologram(data=data_3d, fft_interface=fft_interface, - subtract_mean=subtract_mean, padding=padding) + subtract_mean=subtract_mean, + padding=padding) holo.run_pipeline(filter_name="disk", filter_size=1 / 2) bg = qpretrieve.OffAxisHologram(data=data_3d_bg) bg.process_like(holo) else: # 2d for _ in range(n_transforms): - holo = qpretrieve.OffAxisHologram(data=data_2d, - fft_interface=fft_interface, - subtract_mean=subtract_mean, padding=padding) + holo = qpretrieve.OffAxisHologram( + data=data_2d, + fft_interface=fft_interface, + subtract_mean=subtract_mean, padding=padding) holo.run_pipeline(filter_name="disk", filter_size=1 / 2) bg = qpretrieve.OffAxisHologram(data=edata["bg_data"]) bg.process_like(holo) From 2468569627c5ffbb51c53293b4185875ca58ed67 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Fri, 8 Nov 2024 11:19:52 +0100 Subject: [PATCH 19/31] docs: update README --- docs/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/README.md b/docs/README.md index 694748f..e8441e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ To install the requirements for building the documentation, run To compile the documentation, run + cd docs sphinx-build . _build From 7d9f1ae59dfd3b483142a326d1f45aba667f7a74 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Fri, 8 Nov 2024 16:29:00 +0100 Subject: [PATCH 20/31] ref: use negative indexing for np array shape --- qpretrieve/fourier/base.py | 6 +++--- qpretrieve/interfere/base.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index eb7173b..5f060e6 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -243,15 +243,15 @@ def filter(self, filter_name: str, filter_size: float, # only take shape of a single fft fft_shape=self.fft_origin.shape[-2:]) fft_filtered = self.fft_origin * filt_array - px = int(freq_pos[0] * self.shape[0]) - py = int(freq_pos[1] * self.shape[1]) + px = int(freq_pos[0] * self.shape[-2]) + py = int(freq_pos[1] * self.shape[-1]) fft_used = np.roll(np.roll(fft_filtered, -px, axis=0), -py, axis=1) if scale_to_filter: # Determine the size of the cropping region. # We compute the "radius" of the region, so we can # crop the data left and right from the center of the # Fourier domain. - osize = fft_filtered.shape[0] # square shaped + osize = fft_filtered.shape[-2] # square shaped crad = int(np.ceil(filter_size * osize * scale_to_filter)) ccent = osize // 2 cslice = slice(ccent - crad, ccent + crad) diff --git a/qpretrieve/interfere/base.py b/qpretrieve/interfere/base.py index 0962c82..84e0dd8 100644 --- a/qpretrieve/interfere/base.py +++ b/qpretrieve/interfere/base.py @@ -121,13 +121,13 @@ def compute_filter_size(self, filter_size, filter_size_interpretation, # filter size given in Fourier index (number of Fourier pixels) # The user probably does not know that we are padding in # Fourier space, so we use the unpadded size and translate it. - if filter_size <= 0 or filter_size >= self.fft.shape[0] / 2: + if filter_size <= 0 or filter_size >= self.fft.shape[-2] / 2: raise ValueError("For frequency index interpretation, " + "`filter_size` must be between 0 and " - + f"{self.fft.shape[0] / 2}, got " + + f"{self.fft.shape[-2] / 2}, got " + f"'{filter_size}'!") # convert to frequencies (compatible with fx and fy) - fsize = filter_size / self.fft.shape[0] + fsize = filter_size / self.fft.shape[-2] else: raise ValueError("Invalid value for `filter_size_interpretation`: " + f"'{filter_size_interpretation}'") From d2e2f61ad8d0ef4927fea345a3f71d41fc810e03 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Fri, 8 Nov 2024 17:17:18 +0100 Subject: [PATCH 21/31] enh: add use of 3D array for the FFTFilter init, not incl padding --- qpretrieve/filter.py | 2 +- qpretrieve/fourier/base.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/qpretrieve/filter.py b/qpretrieve/filter.py index c7c6ab4..d14c0eb 100644 --- a/qpretrieve/filter.py +++ b/qpretrieve/filter.py @@ -40,7 +40,7 @@ def get_filter_array(filter_name, filter_size, freq_pos, fft_shape): The position of the filter in frequency coordinates as returned by :func:`numpy.fft.fftfreq`. fft_shape: tuple of int - The shape of the Fourier transformed image for which the + The shape of the Fourier transformed image (2d) for which the filter will be applied. The shape must be squared (two identical integers). diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index 5f060e6..aa8c097 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -245,7 +245,7 @@ def filter(self, filter_name: str, filter_size: float, fft_filtered = self.fft_origin * filt_array px = int(freq_pos[0] * self.shape[-2]) py = int(freq_pos[1] * self.shape[-1]) - fft_used = np.roll(np.roll(fft_filtered, -px, axis=0), -py, axis=1) + fft_used = np.roll(np.roll(fft_filtered, -px, axis=-2), -py, axis=-1) if scale_to_filter: # Determine the size of the cropping region. # We compute the "radius" of the region, so we can @@ -257,7 +257,10 @@ def filter(self, filter_name: str, filter_size: float, cslice = slice(ccent - crad, ccent + crad) # We now have the interesting peak already shifted to # the first entry of our array in `shifted`. - fft_used = fft_used[cslice, cslice] + if len(fft_used.shape) == 2: + fft_used = fft_used[cslice, cslice] + elif len(fft_used.shape) == 3: + fft_used = fft_used[:, cslice, cslice] field = self._ifft(np.fft.ifftshift(fft_used)) if len(self.origin.shape) != 2: From e427cc54cbe28a0fb120197367e6af4e77c2ef82 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Fri, 8 Nov 2024 17:34:37 +0100 Subject: [PATCH 22/31] enh: ensure ifft with padding works with 3D stack --- qpretrieve/fourier/base.py | 15 +++-- .../test_oah_from_qpimage_cupy.py | 58 ++++++++++++------- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index aa8c097..f9806e8 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -245,7 +245,8 @@ def filter(self, filter_name: str, filter_size: float, fft_filtered = self.fft_origin * filt_array px = int(freq_pos[0] * self.shape[-2]) py = int(freq_pos[1] * self.shape[-1]) - fft_used = np.roll(np.roll(fft_filtered, -px, axis=-2), -py, axis=-1) + fft_used = np.roll(np.roll( + fft_filtered, -px, axis=-2), -py, axis=-1) if scale_to_filter: # Determine the size of the cropping region. # We compute the "radius" of the region, so we can @@ -263,16 +264,18 @@ def filter(self, filter_name: str, filter_size: float, fft_used = fft_used[:, cslice, cslice] field = self._ifft(np.fft.ifftshift(fft_used)) - if len(self.origin.shape) != 2: - # todo: this must be corrected - self.padding = False + if self.padding: # revert padding - sx, sy = self.origin.shape + sx, sy = self.origin.shape[-2:] if scale_to_filter: sx = int(np.ceil(sx * 2 * crad / osize)) sy = int(np.ceil(sy * 2 * crad / osize)) - field = field[:sx, :sy] + if len(fft_used.shape) == 2: + field = field[:sx, :sy] + elif len(fft_used.shape) == 3: + field = field[:, :sx, :sy] + if scale_to_filter: # Scale the absolute value of the field. This does not # have any influence on the phase, but on the amplitude. diff --git a/tests/test_cupy_gpu/test_oah_from_qpimage_cupy.py b/tests/test_cupy_gpu/test_oah_from_qpimage_cupy.py index 8a8a1f2..c412485 100644 --- a/tests/test_cupy_gpu/test_oah_from_qpimage_cupy.py +++ b/tests/test_cupy_gpu/test_oah_from_qpimage_cupy.py @@ -5,6 +5,27 @@ from qpretrieve.fourier import FFTFilterCupy3D, FFTFilterCupy, FFTFilterNumpy +def test_get_field_compare_FFTFilters(hologram): + data1 = hologram + + holo1 = qpretrieve.OffAxisHologram(data1, + fft_interface=FFTFilterNumpy, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res1 = holo1.run_pipeline(**kwargs) + + holo1 = qpretrieve.OffAxisHologram(data1, + fft_interface=FFTFilterCupy, + padding=False) + kwargs = dict(filter_name="disk", filter_size=1 / 3) + res2 = holo1.run_pipeline(**kwargs) + + assert res1.shape == (64, 64) + assert res2.shape == (64, 64) + assert not np.all(res1 == res2) + assert np.allclose(res1, res2) + + def test_get_field_cupy3d(hologram): data1 = hologram data_rp = np.array([data1, data1, data1, data1, data1]) @@ -25,30 +46,25 @@ def test_get_field_cupy3d(hologram): assert not np.all(res1[0] == res2) - # import matplotlib.pyplot as plt - # fig, axes = plt.subplots(3, 1) - # ax1, ax2, ax3 = axes - # ax1.imshow(np.abs(res1[0])) - # ax2.imshow(np.abs(res2)) - # ax3.imshow(np.abs(res2)-np.abs(res1[0])) - # plt.show() - -def test_get_field_compare_FFTFilters(hologram): +def test_get_field_cupy3d_scale_to_filter(hologram): data1 = hologram + data_rp = np.array([data1, data1, data1, data1, data1]) - holo1 = qpretrieve.OffAxisHologram(data1, - fft_interface=FFTFilterNumpy, - padding=False) - kwargs = dict(filter_name="disk", filter_size=1 / 3) + holo1 = qpretrieve.OffAxisHologram(data_rp, + fft_interface=FFTFilterCupy3D, + padding=True) + kwargs = dict(filter_name="disk", filter_size=1 / 3, + scale_to_filter=True) res1 = holo1.run_pipeline(**kwargs) - assert res1.shape == (64, 64) - holo1 = qpretrieve.OffAxisHologram(data1, - fft_interface=FFTFilterCupy, - padding=False) - kwargs = dict(filter_name="disk", filter_size=1 / 3) - res2 = holo1.run_pipeline(**kwargs) - assert res2.shape == (64, 64) + holo2 = qpretrieve.OffAxisHologram(data1, + fft_interface=FFTFilterNumpy, + padding=True) + kwargs = dict(filter_name="disk", filter_size=1 / 3, + scale_to_filter=True) + res2 = holo2.run_pipeline(**kwargs) - assert not np.all(res1 == res2) + assert res1.shape == (5, 18, 18) + assert res2.shape == (18, 18) + assert np.allclose(res1[0], res2) From d7d84f5bd49f12a4499251fd8eb5571361c52517 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Wed, 20 Nov 2024 12:03:03 +0100 Subject: [PATCH 23/31] enh: ensure all data is converted to 3D image stack --- qpretrieve/data_input.py | 17 ++++++++++++++++ qpretrieve/interfere/base.py | 9 ++++----- tests/test_data_input.py | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 qpretrieve/data_input.py create mode 100644 tests/test_data_input.py diff --git a/qpretrieve/data_input.py b/qpretrieve/data_input.py new file mode 100644 index 0000000..dd579ee --- /dev/null +++ b/qpretrieve/data_input.py @@ -0,0 +1,17 @@ +import numpy as np + + +def check_data_input(data): + """Figure out what data input is provided.""" + if len(data.shape) == 3: + if data.shape[-1] in [1, 3, 4]: + # take the first slice (we have alpha or RGB information) + data = data[:, :, 0] + else: + # we have a 3D image stack (z, y, x) + pass + if len(data.shape) == 2: + # we have a 2D image (y, x). convert to (z, y, z) + data = data[np.newaxis, :, :] + + return data.copy() diff --git a/qpretrieve/interfere/base.py b/qpretrieve/interfere/base.py index 84e0dd8..c950df8 100644 --- a/qpretrieve/interfere/base.py +++ b/qpretrieve/interfere/base.py @@ -4,6 +4,7 @@ from ..fourier import get_best_interface, get_available_interfaces from ..fourier.base import FFTFilter +from ..data_input import check_data_input class BaseInterferogram(ABC): @@ -59,11 +60,9 @@ def __init__(self, data, fft_interface: FFTFilter = None, f"{get_available_interfaces()}.\n" f"You can use `fft_interface='auto'` to get the best " f"available interface.") - if self.ff_iface.__name__ == "FFTFilterCupy3D": - data = data - elif len(data.shape) == 3: - # take the first slice (we have alpha or RGB information) - data = data[:, :, 0] + + # figure out what type of data we have + data = check_data_input(data) #: qpretrieve Fourier transform interface class self.fft = self.ff_iface(data=data, subtract_mean=subtract_mean, diff --git a/tests/test_data_input.py b/tests/test_data_input.py new file mode 100644 index 0000000..a9c5ee9 --- /dev/null +++ b/tests/test_data_input.py @@ -0,0 +1,39 @@ +import numpy as np + +from qpretrieve.data_input import check_data_input + + +def test_check_data_input_2d(): + data = np.zeros(shape=(256, 256)) + + data_new = check_data_input(data) + + assert data_new.shape == (1, 256, 256) + assert np.array_equal(data_new[0], data) + + +def test_check_data_input_3d_image_stack(): + data = np.zeros(shape=(50, 256, 256)) + + data_new = check_data_input(data) + + assert data_new.shape == (50, 256, 256) + assert np.array_equal(data_new, data) + + +def test_check_data_input_3d_rgb(): + data = np.zeros(shape=(256, 256, 3)) + + data_new = check_data_input(data) + + assert data_new.shape == (1, 256, 256) + assert np.array_equal(data_new[0], data[:, :, 0]) + + +def test_check_data_input_3d_rgba(): + data = np.zeros(shape=(256, 256, 4)) + + data_new = check_data_input(data) + + assert data_new.shape == (1, 256, 256) + assert np.array_equal(data_new[0], data[:, :, 0]) From 2da15828eab9baaa0552cf0ba9ce3b77b2e15a0d Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Wed, 20 Nov 2024 14:40:54 +0100 Subject: [PATCH 24/31] enh: add data format conversion functions --- qpretrieve/data_input.py | 80 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/qpretrieve/data_input.py b/qpretrieve/data_input.py index dd579ee..ebb7738 100644 --- a/qpretrieve/data_input.py +++ b/qpretrieve/data_input.py @@ -1,17 +1,81 @@ import numpy as np +allowed_data_formats = [ + "rgb", + "rgba", + "3d", + "2d", +] -def check_data_input(data): + +def check_data_input_form(data_input): """Figure out what data input is provided.""" - if len(data.shape) == 3: - if data.shape[-1] in [1, 3, 4]: + if len(data_input.shape) == 3: + if data_input.shape[-1] in [1, 2, 3]: + # take the first slice (we have alpha or RGB information) + data, data_format = _convert_rgb_to_3d(data_input) + elif data_input.shape[-1] == 4: # take the first slice (we have alpha or RGB information) - data = data[:, :, 0] + data, data_format = _convert_rgba_to_3d(data_input) else: # we have a 3D image stack (z, y, x) - pass - if len(data.shape) == 2: + data, data_format = data_input, "3d" + elif len(data_input.shape) == 2: # we have a 2D image (y, x). convert to (z, y, z) - data = data[np.newaxis, :, :] + data, data_format = _convert_2d_to_3d(data_input) + else: + raise ValueError(f"data_input shape must be 2d or 3d, " + f"got shape {data_input.shape}.") + return data.copy(), data_format + + +def revert_to_data_input_shape(data_format, field): + """Convert the outputted field shape to the original input shape, + for user convenience.""" + assert data_format in allowed_data_formats + assert len(field.shape) == 3, "the field should be 3d" + field = field.copy() + if data_format == "rgb": + field = _convert_3d_to_rgb(field) + elif data_format == "rgba": + field = _convert_3d_to_rgba(field) + elif data_format == "3d": + field = field + else: + field = _convert_3d_to_2d(field) + return field + + +def _convert_rgb_to_3d(data_input): + data = data_input[:, :, 0] + data = data[np.newaxis, :, :] + data_format = "rgb" + return data, data_format + + +def _convert_rgba_to_3d(data_input): + data, _ = _convert_rgb_to_3d(data_input) + data_format = "rgba" + return data, data_format + + +def _convert_2d_to_3d(data_input): + data = data_input[np.newaxis, :, :] + data_format = "2d" + return data, data_format + + +def _convert_3d_to_rgb(field): + field = field[0] + field = np.dstack((field, field, field)) + return field + + +def _convert_3d_to_rgba(field): + field = field[0] + field = np.dstack((field, field, field, np.ones_like(field))) + return field + - return data.copy() +def _convert_3d_to_2d(field): + return field[0] From 57947e6b0837c312c3d84769d4f9a9c90578d727 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Wed, 20 Nov 2024 14:41:37 +0100 Subject: [PATCH 25/31] ref: use the data format conversion convenience fnuctions to handle fft data input --- qpretrieve/fourier/base.py | 37 ++++++++++++---------------------- qpretrieve/interfere/base.py | 3 --- qpretrieve/interfere/if_oah.py | 4 +++- 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index f9806e8..97b744e 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -6,7 +6,8 @@ import numpy as np from .. import filter -from ..utils import padding_2d, padding_3d, mean_2d, mean_3d +from ..utils import padding_3d, mean_3d +from ..data_input import check_data_input_form class FFTCache: @@ -45,7 +46,10 @@ def __init__(self, Parameters ---------- data - The experimental input image (2d or 3d real-valued) + The experimental input real-valued image. Allowed input shapes are: + - 2d (y, x) + - 3d (z, y, x) + - 3d rgb (y, x, 3) or rgba (y, x, 4) subtract_mean: bool If True, subtract the mean of `data` before performing the Fourier transform. This setting is recommended as it @@ -79,6 +83,8 @@ def __init__(self, # numpy v2.x behaviour requires asarray with copy=False copy = None data_ed = np.array(data, dtype=dtype, copy=copy) + # figure out what type of data we have + data_ed, self.data_format = check_data_input_form(data_ed) #: original data (with subtracted mean) self.origin = data_ed # for `subtract_mean` and `padding`, we could use `np.atleast_3d` @@ -90,25 +96,13 @@ def __init__(self, # remove contributions of the central band # (this affects more than one pixel in the FFT # because of zero-padding) - if len(data_ed.shape) == 2: - data_ed = mean_2d(data_ed) - elif len(data_ed.shape) == 3: - data_ed = mean_3d(data_ed) - else: - raise ValueError(f"FFTFilter `data` input must be 2D or 3D, " - f"got {len(data_ed.shape)=}.") + data_ed = mean_3d(data_ed) if padding: # zero padding size is next order of 2 logfact = np.log(padding * max(data_ed.shape)) order = int(2 ** np.ceil(logfact / np.log(2))) - if len(data_ed.shape) == 2: - datapad = padding_2d(data_ed, order, dtype) - elif len(data_ed.shape) == 3: - datapad = padding_3d(data_ed, order, dtype) - else: - raise ValueError(f"FFTFilter `data` input must be 2D or 3D, " - f"got {len(data_ed.shape)=}.") + datapad = padding_3d(data_ed, order, dtype) #: padded input data self.origin_padded = datapad data_ed = datapad @@ -258,10 +252,7 @@ def filter(self, filter_name: str, filter_size: float, cslice = slice(ccent - crad, ccent + crad) # We now have the interesting peak already shifted to # the first entry of our array in `shifted`. - if len(fft_used.shape) == 2: - fft_used = fft_used[cslice, cslice] - elif len(fft_used.shape) == 3: - fft_used = fft_used[:, cslice, cslice] + fft_used = fft_used[:, cslice, cslice] field = self._ifft(np.fft.ifftshift(fft_used)) @@ -271,10 +262,8 @@ def filter(self, filter_name: str, filter_size: float, if scale_to_filter: sx = int(np.ceil(sx * 2 * crad / osize)) sy = int(np.ceil(sy * 2 * crad / osize)) - if len(fft_used.shape) == 2: - field = field[:sx, :sy] - elif len(fft_used.shape) == 3: - field = field[:, :sx, :sy] + + field = field[:, :sx, :sy] if scale_to_filter: # Scale the absolute value of the field. This does not diff --git a/qpretrieve/interfere/base.py b/qpretrieve/interfere/base.py index c950df8..c5f3ec0 100644 --- a/qpretrieve/interfere/base.py +++ b/qpretrieve/interfere/base.py @@ -4,7 +4,6 @@ from ..fourier import get_best_interface, get_available_interfaces from ..fourier.base import FFTFilter -from ..data_input import check_data_input class BaseInterferogram(ABC): @@ -61,8 +60,6 @@ def __init__(self, data, fft_interface: FFTFilter = None, f"You can use `fft_interface='auto'` to get the best " f"available interface.") - # figure out what type of data we have - data = check_data_input(data) #: qpretrieve Fourier transform interface class self.fft = self.ff_iface(data=data, subtract_mean=subtract_mean, diff --git a/qpretrieve/interfere/if_oah.py b/qpretrieve/interfere/if_oah.py index 3567501..7cd96ce 100644 --- a/qpretrieve/interfere/if_oah.py +++ b/qpretrieve/interfere/if_oah.py @@ -1,6 +1,7 @@ import numpy as np from .base import BaseInterferogram +from ..data_input import revert_to_data_input_shape class OffAxisHologram(BaseInterferogram): @@ -92,6 +93,7 @@ def run_pipeline(self, **pipeline_kws): if pipeline_kws["invert_phase"]: field.imag *= -1 + field = revert_to_data_input_shape(self.fft.data_format, field) self._field = field self._phase = None self._amplitude = None @@ -129,7 +131,7 @@ def find_peak_cosine(ft_data, copy=True): if len(ft_data.shape) == 3: # then we have a stack of images, just take one for finding the peak ft_data = ft_data[0] - + assert len(ft_data.shape) == 2 ox, oy = ft_data.shape cx = ox // 2 cy = oy // 2 From 1ac98ff677bfbe9649ec48fd97e8697d922f35bd Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Wed, 20 Nov 2024 14:42:21 +0100 Subject: [PATCH 26/31] test: refactor relevant oah tests to expect new format and shape --- tests/test_data_input.py | 14 +++++++++----- tests/test_oah_from_qpimage.py | 26 +++++++++++++++++--------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/tests/test_data_input.py b/tests/test_data_input.py index a9c5ee9..d5f08a5 100644 --- a/tests/test_data_input.py +++ b/tests/test_data_input.py @@ -1,39 +1,43 @@ import numpy as np -from qpretrieve.data_input import check_data_input +from qpretrieve.data_input import check_data_input_form def test_check_data_input_2d(): data = np.zeros(shape=(256, 256)) - data_new = check_data_input(data) + data_new, data_format = check_data_input_form(data) assert data_new.shape == (1, 256, 256) assert np.array_equal(data_new[0], data) + assert data_format == "2d" def test_check_data_input_3d_image_stack(): data = np.zeros(shape=(50, 256, 256)) - data_new = check_data_input(data) + data_new, data_format = check_data_input_form(data) assert data_new.shape == (50, 256, 256) assert np.array_equal(data_new, data) + assert data_format == "3d" def test_check_data_input_3d_rgb(): data = np.zeros(shape=(256, 256, 3)) - data_new = check_data_input(data) + data_new, data_format = check_data_input_form(data) assert data_new.shape == (1, 256, 256) assert np.array_equal(data_new[0], data[:, :, 0]) + assert data_format == "rgb" def test_check_data_input_3d_rgba(): data = np.zeros(shape=(256, 256, 4)) - data_new = check_data_input(data) + data_new, data_format = check_data_input_form(data) assert data_new.shape == (1, 256, 256) assert np.array_equal(data_new[0], data[:, :, 0]) + assert data_format == "rgba" diff --git a/tests/test_oah_from_qpimage.py b/tests/test_oah_from_qpimage.py index 6f568fc..4ff5686 100644 --- a/tests/test_oah_from_qpimage.py +++ b/tests/test_oah_from_qpimage.py @@ -19,12 +19,13 @@ def test_find_sideband(): def test_fourier2dpad(): - data = np.zeros((100, 120)) + y, x = 100, 120 + data = np.zeros((y, x)) fft1 = qpretrieve.fourier.FFTFilterNumpy(data) - assert fft1.shape == (256, 256) + assert fft1.shape == (1, 256, 256) fft2 = qpretrieve.fourier.FFTFilterNumpy(data, padding=False) - assert fft2.shape == data.shape + assert fft2.shape == (1, y, x) def test_get_field_error_bad_filter_size(hologram): @@ -109,12 +110,15 @@ def test_get_field_interpretation_fourier_index(hologram): filter_size_interpretation="sideband distance") res1 = holo.run_pipeline(**kwargs1) - filter_size_fi = np.sqrt(fsx ** 2 + fsy ** 2) / 3 * ft_data.shape[0] + filter_size_fi = np.sqrt(fsx ** 2 + fsy ** 2) / 3 * ft_data.shape[-2] kwargs2 = dict(filter_name="disk", filter_size=filter_size_fi, filter_size_interpretation="frequency index", ) res2 = holo.run_pipeline(**kwargs2) + + assert res1.shape == hologram.shape + assert res2.shape == hologram.shape assert np.all(res1 == res2) @@ -136,7 +140,7 @@ def test_get_field_interpretation_fourier_index_control(hologram): ) res1 = holo.run_pipeline(**kwargs1) - filter_size_fi = np.sqrt(fsx ** 2 + fsy ** 2) / 3 * ft_data.shape[0] + filter_size_fi = np.sqrt(fsx ** 2 + fsy ** 2) / 3 * ft_data.shape[-2] kwargs2 = dict(filter_name="disk", filter_size=filter_size_fi, filter_size_interpretation="frequency index", @@ -163,7 +167,7 @@ def test_get_field_interpretation_fourier_index_mask_1(hologram, filter_size): # We get 17*2+1, because we measure from the center of Fourier # space and a pixel is included if its center is withing the # perimeter of the disk. - assert np.sum(np.sum(mask, axis=0) != 0) == 17 * 2 + 1 + assert np.sum(np.sum(mask, axis=-2) != 0) == 17 * 2 + 1 @pytest.mark.parametrize("hologram", [62, 63, 64, 134, 135], @@ -182,7 +186,7 @@ def test_get_field_interpretation_fourier_index_mask_2(hologram): # We get two points less than in the previous test, because we # loose on each side of the spectrum. - assert np.sum(np.sum(mask, axis=0) != 0) == 17 * 2 - 1 + assert np.sum(np.sum(mask, axis=-2) != 0) == 17 * 2 - 1 def test_get_field_int_copy(hologram): @@ -221,7 +225,7 @@ def test_get_field_sideband(hologram): def test_get_field_three_axes(hologram): data1 = hologram # create a copy with empty entry in third axis - data2 = np.zeros((data1.shape[0], data1.shape[1], 2)) + data2 = np.zeros((data1.shape[0], data1.shape[1], 3)) data2[:, :, 0] = data1 holo1 = qpretrieve.OffAxisHologram(data1) @@ -231,7 +235,11 @@ def test_get_field_three_axes(hologram): filter_size=1 / 3) res1 = holo1.run_pipeline(**kwargs) res2 = holo2.run_pipeline(**kwargs) - assert np.all(res1 == res2) + + assert res1.shape == (data1.shape[0], data1.shape[1]) + assert res2.shape == (data1.shape[0], data1.shape[1], 3) + + assert np.all(res1 == res2[:, :, 0]) def test_get_field_compare_FFTFilters(hologram): From fe33bf083bdacb5825dc050e1e0e5806ccf740f3 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Wed, 20 Nov 2024 15:25:51 +0100 Subject: [PATCH 27/31] enh: add rgb warning for user --- qpretrieve/data_input.py | 3 +++ tests/test_data_input.py | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/qpretrieve/data_input.py b/qpretrieve/data_input.py index ebb7738..b351ef5 100644 --- a/qpretrieve/data_input.py +++ b/qpretrieve/data_input.py @@ -1,4 +1,5 @@ import numpy as np +import warnings allowed_data_formats = [ "rgb", @@ -50,6 +51,8 @@ def _convert_rgb_to_3d(data_input): data = data_input[:, :, 0] data = data[np.newaxis, :, :] data_format = "rgb" + warnings.warn(f"Format of input data detected as {data_format}. " + f"The first channel will be used for processing") return data, data_format diff --git a/tests/test_data_input.py b/tests/test_data_input.py index d5f08a5..b65267e 100644 --- a/tests/test_data_input.py +++ b/tests/test_data_input.py @@ -1,4 +1,5 @@ import numpy as np +import pytest from qpretrieve.data_input import check_data_input_form @@ -26,7 +27,8 @@ def test_check_data_input_3d_image_stack(): def test_check_data_input_3d_rgb(): data = np.zeros(shape=(256, 256, 3)) - data_new, data_format = check_data_input_form(data) + with pytest.warns(UserWarning): + data_new, data_format = check_data_input_form(data) assert data_new.shape == (1, 256, 256) assert np.array_equal(data_new[0], data[:, :, 0]) @@ -36,7 +38,8 @@ def test_check_data_input_3d_rgb(): def test_check_data_input_3d_rgba(): data = np.zeros(shape=(256, 256, 4)) - data_new, data_format = check_data_input_form(data) + with pytest.warns(UserWarning): + data_new, data_format = check_data_input_form(data) assert data_new.shape == (1, 256, 256) assert np.array_equal(data_new[0], data[:, :, 0]) From 568d511abb681b4e7de4bf377aa81221bb3b18fc Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Wed, 20 Nov 2024 15:43:22 +0100 Subject: [PATCH 28/31] test: ensure the users provided data format is returned --- qpretrieve/data_input.py | 26 +++++++++++++------------- tests/test_oah_from_qpimage.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/qpretrieve/data_input.py b/qpretrieve/data_input.py index b351ef5..d9b509a 100644 --- a/qpretrieve/data_input.py +++ b/qpretrieve/data_input.py @@ -37,13 +37,13 @@ def revert_to_data_input_shape(data_format, field): assert len(field.shape) == 3, "the field should be 3d" field = field.copy() if data_format == "rgb": - field = _convert_3d_to_rgb(field) + field = _revert_3d_to_rgb(field) elif data_format == "rgba": - field = _convert_3d_to_rgba(field) + field = _revert_3d_to_rgba(field) elif data_format == "3d": field = field else: - field = _convert_3d_to_2d(field) + field = _revert_3d_to_2d(field) return field @@ -68,17 +68,17 @@ def _convert_2d_to_3d(data_input): return data, data_format -def _convert_3d_to_rgb(field): - field = field[0] - field = np.dstack((field, field, field)) - return field +def _revert_3d_to_rgb(data_input): + data = data_input[0] + data = np.dstack((data, data, data)) + return data -def _convert_3d_to_rgba(field): - field = field[0] - field = np.dstack((field, field, field, np.ones_like(field))) - return field +def _revert_3d_to_rgba(data_input): + data = data_input[0] + data = np.dstack((data, data, data, np.ones_like(data))) + return data -def _convert_3d_to_2d(field): - return field[0] +def _revert_3d_to_2d(data_input): + return data_input[0] diff --git a/tests/test_oah_from_qpimage.py b/tests/test_oah_from_qpimage.py index 4ff5686..5904c5a 100644 --- a/tests/test_oah_from_qpimage.py +++ b/tests/test_oah_from_qpimage.py @@ -5,6 +5,9 @@ import qpretrieve from qpretrieve.interfere import if_oah from qpretrieve.fourier import FFTFilterNumpy, FFTFilterScipy, FFTFilterPyFFTW +from qpretrieve.data_input import ( + _convert_2d_to_3d, _revert_3d_to_rgb, _revert_3d_to_rgba, +) def test_find_sideband(): @@ -268,3 +271,31 @@ def test_get_field_compare_FFTFilters(hologram): assert not np.all(res1 == res2) assert not np.all(res2 == res3) + + +def test_field_format_consistency(hologram): + """The data format provided by the user should be returned""" + data_2d = hologram + + # 2d data format + holo1 = qpretrieve.OffAxisHologram(data_2d) + res1 = holo1.run_pipeline() + assert res1.shape == data_2d.shape + + # 3d data format + data_3d, _ = _convert_2d_to_3d(data_2d) + holo_3d = qpretrieve.OffAxisHologram(data_3d) + res_3d = holo_3d.run_pipeline() + assert res_3d.shape == data_3d.shape + + # rgb data format + data_rgb = _revert_3d_to_rgb(data_3d) + holo_rgb = qpretrieve.OffAxisHologram(data_rgb) + res_rgb = holo_rgb.run_pipeline() + assert res_rgb.shape == data_rgb.shape + + # rgba data format + data_rgba = _revert_3d_to_rgba(data_3d) + holo_rgba = qpretrieve.OffAxisHologram(data_rgba) + res_rgba = holo_rgba.run_pipeline() + assert res_rgba.shape == data_rgba.shape From 7d88bb9cf879f137bb3a907260af027d3a39d9f0 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Wed, 20 Nov 2024 16:46:11 +0100 Subject: [PATCH 29/31] enh: add 3d array usage to qsli --- qpretrieve/data_input.py | 4 ++-- qpretrieve/fourier/base.py | 4 ++-- qpretrieve/interfere/if_oah.py | 12 ++++-------- qpretrieve/interfere/if_qlsi.py | 29 ++++++++++++++++------------- tests/test_data_input.py | 10 +++++----- tests/test_fourier_base.py | 14 ++++++++++++-- 6 files changed, 41 insertions(+), 32 deletions(-) diff --git a/qpretrieve/data_input.py b/qpretrieve/data_input.py index d9b509a..7eef4d1 100644 --- a/qpretrieve/data_input.py +++ b/qpretrieve/data_input.py @@ -9,7 +9,7 @@ ] -def check_data_input_form(data_input): +def check_data_input_format(data_input): """Figure out what data input is provided.""" if len(data_input.shape) == 3: if data_input.shape[-1] in [1, 2, 3]: @@ -30,7 +30,7 @@ def check_data_input_form(data_input): return data.copy(), data_format -def revert_to_data_input_shape(data_format, field): +def revert_to_data_input_format(data_format, field): """Convert the outputted field shape to the original input shape, for user convenience.""" assert data_format in allowed_data_formats diff --git a/qpretrieve/fourier/base.py b/qpretrieve/fourier/base.py index 97b744e..e6a13c2 100644 --- a/qpretrieve/fourier/base.py +++ b/qpretrieve/fourier/base.py @@ -7,7 +7,7 @@ from .. import filter from ..utils import padding_3d, mean_3d -from ..data_input import check_data_input_form +from ..data_input import check_data_input_format class FFTCache: @@ -84,7 +84,7 @@ def __init__(self, copy = None data_ed = np.array(data, dtype=dtype, copy=copy) # figure out what type of data we have - data_ed, self.data_format = check_data_input_form(data_ed) + data_ed, self.data_format = check_data_input_format(data_ed) #: original data (with subtracted mean) self.origin = data_ed # for `subtract_mean` and `padding`, we could use `np.atleast_3d` diff --git a/qpretrieve/interfere/if_oah.py b/qpretrieve/interfere/if_oah.py index 7cd96ce..707d253 100644 --- a/qpretrieve/interfere/if_oah.py +++ b/qpretrieve/interfere/if_oah.py @@ -1,7 +1,7 @@ import numpy as np from .base import BaseInterferogram -from ..data_input import revert_to_data_input_shape +from ..data_input import revert_to_data_input_format class OffAxisHologram(BaseInterferogram): @@ -74,7 +74,7 @@ def run_pipeline(self, **pipeline_kws): if pipeline_kws["sideband_freq"] is None: pipeline_kws["sideband_freq"] = find_peak_cosine( - self.fft.fft_origin) + self.fft.fft_origin[0]) # convert filter_size to frequency coordinates fsize = self.compute_filter_size( @@ -93,7 +93,7 @@ def run_pipeline(self, **pipeline_kws): if pipeline_kws["invert_phase"]: field.imag *= -1 - field = revert_to_data_input_shape(self.fft.data_format, field) + field = revert_to_data_input_format(self.fft.data_format, field) self._field = field self._phase = None self._amplitude = None @@ -103,7 +103,7 @@ def run_pipeline(self, **pipeline_kws): def find_peak_cosine(ft_data, copy=True): - """Find the side band position of a regular off-axis hologram + """Find the side band position of a 2d regular off-axis hologram The Fourier transform of a cosine function (known as the striped fringe pattern in off-axis holography) results in @@ -128,10 +128,6 @@ def find_peak_cosine(ft_data, copy=True): if copy: ft_data = ft_data.copy() - if len(ft_data.shape) == 3: - # then we have a stack of images, just take one for finding the peak - ft_data = ft_data[0] - assert len(ft_data.shape) == 2 ox, oy = ft_data.shape cx = ox // 2 cy = oy // 2 diff --git a/qpretrieve/interfere/if_qlsi.py b/qpretrieve/interfere/if_qlsi.py index 38f2c39..8000b8b 100644 --- a/qpretrieve/interfere/if_qlsi.py +++ b/qpretrieve/interfere/if_qlsi.py @@ -6,6 +6,7 @@ from .base import BaseInterferogram from ..fourier import get_best_interface +from ..data_input import revert_to_data_input_format class QLSInterferogram(BaseInterferogram): @@ -47,7 +48,7 @@ def amplitude(self): @property def field(self): if self._field is None: - self._field = self.amplitude * np.exp(1j*2*np.pi*self.phase) + self._field = self.amplitude * np.exp(1j * 2 * np.pi * self.phase) return self._field @property @@ -120,7 +121,7 @@ def run_pipeline(self, **pipeline_kws): if pipeline_kws["sideband_freq"] is None: pipeline_kws["sideband_freq"] = find_peaks_qlsi( - self.fft.fft_origin) + self.fft.fft_origin[0]) # convert filter_size to frequency coordinates fsize = self.compute_filter_size( @@ -183,10 +184,10 @@ def run_pipeline(self, **pipeline_kws): # Pad the gradient information so that we can rotate with cropping # (keeping the image shape the same). # TODO: Make padding dependent on rotation angle to save time? - sx, sy = px.shape - gradpad1 = np.pad(px, ((sx // 2, sx // 2), (sy // 2, sy // 2)), + sx, sy = px.shape[-2:] + gradpad1 = np.pad(px, ((0, 0), (sx // 2, sx // 2), (sy // 2, sy // 2)), mode="constant", constant_values=0) - gradpad2 = np.pad(py, ((sx // 2, sx // 2), (sy // 2, sy // 2)), + gradpad2 = np.pad(py, ((0, 0), (sx // 2, sx // 2), (sy // 2, sy // 2)), mode="constant", constant_values=0) # Perform rotation of the gradients. @@ -204,19 +205,19 @@ def run_pipeline(self, **pipeline_kws): copy=False) # Compute the frequencies that correspond to the frequencies of the # Fourier-transformed image. - fx = np.fft.fftfreq(rfft.shape[0]).reshape(-1, 1) - fy = np.fft.fftfreq(rfft.shape[1]).reshape(1, -1) - fxy = -2*np.pi*1j * (fx + 1j*fy) + fx = np.fft.fftfreq(rfft.shape[-1]).reshape(rfft.shape[0], -1, 1) + fy = np.fft.fftfreq(rfft.shape[-2]).reshape(rfft.shape[0], 1, -1) + fxy = -2 * np.pi * 1j * (fx + 1j * fy) fxy[0, 0] = 1 # The wavefront is the real part of the inverse Fourier transform # of the filtered (divided by frequencies) data. - wfr = rfft._ifft(np.fft.ifftshift(rfft.fft_origin)/fxy).real + wfr = rfft._ifft(np.fft.ifftshift(rfft.fft_origin) / fxy).real # Rotate the wavefront back and crop it so that the FOV matches # the input data. - raw_wavefront = rotate_noreshape(wfr, - angle)[sx//2:-sx//2, sy//2:-sy//2] + raw_wavefront = rotate_noreshape( + wfr, angle)[:, sx // 2:-sx // 2, sy // 2:-sy // 2] # Multiply by qlsi pitch term and the scaling factor to get # the quantitative wavefront. scaling_factor = self.fft_origin.shape[0] / wfr.shape[0] @@ -230,6 +231,8 @@ def run_pipeline(self, **pipeline_kws): self.pipeline_kws.update(pipeline_kws) + raw_wavefront = revert_to_data_input_format( + self.fft.data_format, raw_wavefront) self.wavefront = raw_wavefront return raw_wavefront @@ -287,12 +290,12 @@ def find_peaks_qlsi(ft_data, periodicity=4, copy=True): # circular bandpass according to periodicity fx = np.fft.fftshift(np.fft.fftfreq(ft_data.shape[0])).reshape(-1, 1) fy = np.fft.fftshift(np.fft.fftfreq(ft_data.shape[1])).reshape(1, -1) - frmask1 = np.sqrt(fx**2 + fy**2) > 1/(periodicity*.8) + frmask1 = np.sqrt(fx ** 2 + fy ** 2) > 1 / (periodicity * .8) frmask2 = np.sqrt(fx ** 2 + fy ** 2) < 1 / (periodicity * 1.2) ft_data[np.logical_or(frmask1, frmask2)] = 0 # find the peak in the left part - am1 = np.argmax(np.abs(ft_data*(fy < 0))) + am1 = np.argmax(np.abs(ft_data * (fy < 0))) i1y = am1 % oy i1x = int((am1 - i1y) / oy) diff --git a/tests/test_data_input.py b/tests/test_data_input.py index b65267e..bd04222 100644 --- a/tests/test_data_input.py +++ b/tests/test_data_input.py @@ -1,13 +1,13 @@ import numpy as np import pytest -from qpretrieve.data_input import check_data_input_form +from qpretrieve.data_input import check_data_input_format def test_check_data_input_2d(): data = np.zeros(shape=(256, 256)) - data_new, data_format = check_data_input_form(data) + data_new, data_format = check_data_input_format(data) assert data_new.shape == (1, 256, 256) assert np.array_equal(data_new[0], data) @@ -17,7 +17,7 @@ def test_check_data_input_2d(): def test_check_data_input_3d_image_stack(): data = np.zeros(shape=(50, 256, 256)) - data_new, data_format = check_data_input_form(data) + data_new, data_format = check_data_input_format(data) assert data_new.shape == (50, 256, 256) assert np.array_equal(data_new, data) @@ -28,7 +28,7 @@ def test_check_data_input_3d_rgb(): data = np.zeros(shape=(256, 256, 3)) with pytest.warns(UserWarning): - data_new, data_format = check_data_input_form(data) + data_new, data_format = check_data_input_format(data) assert data_new.shape == (1, 256, 256) assert np.array_equal(data_new[0], data[:, :, 0]) @@ -39,7 +39,7 @@ def test_check_data_input_3d_rgba(): data = np.zeros(shape=(256, 256, 4)) with pytest.warns(UserWarning): - data_new, data_format = check_data_input_form(data) + data_new, data_format = check_data_input_format(data) assert data_new.shape == (1, 256, 256) assert np.array_equal(data_new[0], data[:, :, 0]) diff --git a/tests/test_fourier_base.py b/tests/test_fourier_base.py index c364ebb..345d074 100644 --- a/tests/test_fourier_base.py +++ b/tests/test_fourier_base.py @@ -136,12 +136,22 @@ def test_scale_to_filter_qlsi(): } ifh = interfere.QLSInterferogram(image, **pipeline_kws) - ifh.run_pipeline() + raw_wavefront = ifh.run_pipeline() + assert raw_wavefront.shape == (720, 720) + assert ifh.phase.shape == (1, 720, 720) + assert ifh.amplitude.shape == (1, 720, 720) + assert ifh.field.shape == (1, 720, 720) ifr = interfere.QLSInterferogram(refer, **pipeline_kws) ifr.run_pipeline() + assert ifr.phase.shape == (1, 720, 720) + assert ifr.amplitude.shape == (1, 720, 720) + assert ifr.field.shape == (1, 720, 720) - phase = unwrap_phase(ifh.phase - ifr.phase) + ifh_phase = ifh.phase[0] + ifr_phase = ifr.phase[0] + + phase = unwrap_phase(ifh_phase - ifr_phase) assert phase.shape == (720, 720) assert np.allclose(phase.mean(), 0.12434563269684816, atol=1e-6) From d7457e16dbe80fcd68dd84c767748db4e5d85858 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Thu, 21 Nov 2024 16:16:10 +0100 Subject: [PATCH 30/31] ref: align the 2d qlsi code to 3d --- qpretrieve/interfere/if_qlsi.py | 30 +++++---- tests/test_fourier_base.py | 1 + tests/test_qlsi.py | 108 +++++++++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 13 deletions(-) diff --git a/qpretrieve/interfere/if_qlsi.py b/qpretrieve/interfere/if_qlsi.py index 8000b8b..917a8ad 100644 --- a/qpretrieve/interfere/if_qlsi.py +++ b/qpretrieve/interfere/if_qlsi.py @@ -6,7 +6,6 @@ from .base import BaseInterferogram from ..fourier import get_best_interface -from ..data_input import revert_to_data_input_format class QLSInterferogram(BaseInterferogram): @@ -173,8 +172,14 @@ def run_pipeline(self, **pipeline_kws): # Obtain the phase gradients in x and y by taking the argument # of Hx and Hy. - px = unwrap_phase(np.angle(hx)) - py = unwrap_phase(np.angle(hy)) + # need to do this along the z axis, as skimage `unwrap_3d` does not + # work for our use-case + # todo: maybe use np.unwrap for the xy axes instead + px = np.zeros_like(hx) + py = np.zeros_like(hy) + for i, (_hx, _hy) in enumerate(zip(hx, hy)): + px[i] = unwrap_phase(np.angle(_hx)) + py[i] = unwrap_phase(np.angle(_hy)) # Determine the angle by which we have to rotate the gradients in # order for them to be aligned with x and y. This angle is defined @@ -191,8 +196,8 @@ def run_pipeline(self, **pipeline_kws): mode="constant", constant_values=0) # Perform rotation of the gradients. - rotated1 = rotate_noreshape(gradpad1, -angle) - rotated2 = rotate_noreshape(gradpad2, -angle) + rotated1 = rotate_noreshape(gradpad1, -angle, axes=(-1, -2)) + rotated2 = rotate_noreshape(gradpad2, -angle, axes=(-1, -2)) # Retrieve the wavefront by integrating the vectorial components # (integrate the total differential). This magical approach @@ -217,10 +222,10 @@ def run_pipeline(self, **pipeline_kws): # Rotate the wavefront back and crop it so that the FOV matches # the input data. raw_wavefront = rotate_noreshape( - wfr, angle)[:, sx // 2:-sx // 2, sy // 2:-sy // 2] + wfr, angle, axes=(-1, -2))[:, sx // 2:-sx // 2, sy // 2:-sy // 2] # Multiply by qlsi pitch term and the scaling factor to get # the quantitative wavefront. - scaling_factor = self.fft_origin.shape[0] / wfr.shape[0] + scaling_factor = self.fft_origin.shape[-2] / wfr.shape[-2] raw_wavefront *= qlsi_pitch_term * scaling_factor self._phase = raw_wavefront / wavelength * 2 * np.pi @@ -231,8 +236,8 @@ def run_pipeline(self, **pipeline_kws): self.pipeline_kws.update(pipeline_kws) - raw_wavefront = revert_to_data_input_format( - self.fft.data_format, raw_wavefront) + # raw_wavefront = revert_to_data_input_format( + # self.fft.data_format, raw_wavefront) self.wavefront = raw_wavefront return raw_wavefront @@ -288,8 +293,8 @@ def find_peaks_qlsi(ft_data, periodicity=4, copy=True): ft_data[:, cy - 3:cy + 3] = 0 # circular bandpass according to periodicity - fx = np.fft.fftshift(np.fft.fftfreq(ft_data.shape[0])).reshape(-1, 1) - fy = np.fft.fftshift(np.fft.fftfreq(ft_data.shape[1])).reshape(1, -1) + fx = np.fft.fftshift(np.fft.fftfreq(ft_data.shape[-2])).reshape(-1, 1) + fy = np.fft.fftshift(np.fft.fftfreq(ft_data.shape[-1])).reshape(1, -1) frmask1 = np.sqrt(fx ** 2 + fy ** 2) > 1 / (periodicity * .8) frmask2 = np.sqrt(fx ** 2 + fy ** 2) < 1 / (periodicity * 1.2) ft_data[np.logical_or(frmask1, frmask2)] = 0 @@ -302,10 +307,11 @@ def find_peaks_qlsi(ft_data, periodicity=4, copy=True): return fx[i1x, 0], fy[0, i1y] -def rotate_noreshape(arr, angle, mode="mirror", reshape=False): +def rotate_noreshape(arr, angle, axes, mode="mirror", reshape=False): return scipy.ndimage.rotate( arr, # input angle=np.rad2deg(angle), # angle + axes=axes, reshape=reshape, # reshape order=0, # order mode=mode, # mode diff --git a/tests/test_fourier_base.py b/tests/test_fourier_base.py index 345d074..fbfe673 100644 --- a/tests/test_fourier_base.py +++ b/tests/test_fourier_base.py @@ -152,6 +152,7 @@ def test_scale_to_filter_qlsi(): ifr_phase = ifr.phase[0] phase = unwrap_phase(ifh_phase - ifr_phase) + assert phase.shape == (720, 720) assert np.allclose(phase.mean(), 0.12434563269684816, atol=1e-6) diff --git a/tests/test_qlsi.py b/tests/test_qlsi.py index f839114..d151fb9 100644 --- a/tests/test_qlsi.py +++ b/tests/test_qlsi.py @@ -1,9 +1,11 @@ import pathlib +import pytest import h5py import numpy as np -import qpretrieve +from skimage.restoration import unwrap_phase +import qpretrieve data_path = pathlib.Path(__file__).parent / "data" @@ -29,3 +31,107 @@ def test_qlsi_phase(): assert qlsi.phase.argmax() == 242294 assert np.allclose(qlsi.phase.max(), 0.9343997734657971, atol=0, rtol=1e-12) + + +def test_qlsi_fftfreq_reshape_2d_3d(hologram): + data_2d = hologram + data_3d, _ = qpretrieve.data_input._convert_2d_to_3d(data_2d) + + fx_2d = np.fft.fftfreq(data_2d.shape[-1]).reshape(-1, 1) + fx_3d = np.fft.fftfreq(data_3d.shape[-1]).reshape(data_3d.shape[0], -1, 1) + + fy_2d = np.fft.fftfreq(data_2d.shape[-2]).reshape(1, -1) + fy_3d = np.fft.fftfreq(data_3d.shape[-2]).reshape(data_3d.shape[0], 1, -1) + + assert np.array_equal(fx_2d, fx_3d[0]) + assert np.array_equal(fy_2d, fy_3d[0]) + + +@pytest.mark.xfail +def test_qlsi_unwrap_phase_2d_3d(): + """ + Check whether skimage unwrap_2d and unwrap_3d give the same result. + In other words, does unwrap_3d apply th unwrapping along the z axis. + + Answer is no, they are different. unwrap_3d is designed for 3D data that + is to be unwrapped on all axes at once. + + """ + with h5py.File(data_path / "qlsi_paa_bead.h5") as h5: + image = h5["0"][:] + + # Standard analysis pipeline + pipeline_kws = { + 'wavelength': 550e-9, + 'qlsi_pitch_term': 1.87711e-08, + 'filter_name': "disk", + 'filter_size': 180, + 'filter_size_interpretation': "frequency index", + 'scale_to_filter': False, + 'invert_phase': False + } + + data_2d = image + data_3d, _ = qpretrieve.data_input._convert_2d_to_3d(data_2d) + + ft_2d = qpretrieve.fourier.FFTFilterNumpy(data_2d, subtract_mean=False) + ft_3d = qpretrieve.fourier.FFTFilterNumpy(data_3d, subtract_mean=False) + + pipeline_kws["sideband_freq"] = qpretrieve.interfere. \ + if_qlsi.find_peaks_qlsi(ft_2d.fft_origin[0]) + + hx_2d = ft_2d.filter(filter_name=pipeline_kws["filter_name"], + filter_size=pipeline_kws["filter_size"], + scale_to_filter=pipeline_kws["scale_to_filter"], + freq_pos=pipeline_kws["sideband_freq"]) + hx_3d = ft_3d.filter(filter_name=pipeline_kws["filter_name"], + filter_size=pipeline_kws["filter_size"], + scale_to_filter=pipeline_kws["scale_to_filter"], + freq_pos=pipeline_kws["sideband_freq"]) + + assert np.array_equal(hx_2d, hx_3d) + + px_3d_loop = np.zeros_like(hx_3d) + for i, _hx in enumerate(hx_3d): + px_3d_loop[i] = unwrap_phase(np.angle(_hx)) + + px_2d = unwrap_phase(np.angle(hx_2d[0])) + px_3d = unwrap_phase(np.angle(hx_3d)) + + assert np.array_equal(px_2d, px_3d_loop[0]) # this passes + assert np.array_equal(px_2d, px_3d[0]) # this fails + + +def test_qlsi_rotate_2d_3d(hologram): + data_2d = hologram + data_3d, _ = qpretrieve.data_input._convert_2d_to_3d(data_2d) + + rot_2d = qpretrieve.interfere.if_qlsi.rotate_noreshape( + data_2d, + angle=2, + axes=(1, 0), # this was the default used before + reshape=False, + ) + rot_3d = qpretrieve.interfere.if_qlsi.rotate_noreshape( + data_3d, + angle=2, + axes=(-1, -2), # the y and x axes + reshape=False, + ) + + assert np.array_equal(rot_2d, rot_3d[0]) + + +def test_qlsi_pad_2d_3d(hologram): + data_2d = hologram + data_3d, _ = qpretrieve.data_input._convert_2d_to_3d(data_2d) + + sx, sy = data_2d.shape[-2:] + gradpad_2d = np.pad( + data_2d, ((sx // 2, sx // 2), (sy // 2, sy // 2)), + mode="constant", constant_values=0) + gradpad_3d = np.pad( + data_3d, ((0, 0), (sx // 2, sx // 2), (sy // 2, sy // 2)), + mode="constant", constant_values=0) + + assert np.array_equal(gradpad_2d, gradpad_3d[0]) From 528a038f0573f89866cbf77a5a8d31e0ccbef761 Mon Sep 17 00:00:00 2001 From: Eoghan O'Connell Date: Thu, 5 Dec 2024 11:08:32 +0100 Subject: [PATCH 31/31] fix: match qlsi 2d with new 3d implementation --- qpretrieve/interfere/if_qlsi.py | 10 +++++----- tests/test_fourier_base.py | 4 ++-- tests/test_qlsi.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/qpretrieve/interfere/if_qlsi.py b/qpretrieve/interfere/if_qlsi.py index 917a8ad..2b107ec 100644 --- a/qpretrieve/interfere/if_qlsi.py +++ b/qpretrieve/interfere/if_qlsi.py @@ -175,8 +175,8 @@ def run_pipeline(self, **pipeline_kws): # need to do this along the z axis, as skimage `unwrap_3d` does not # work for our use-case # todo: maybe use np.unwrap for the xy axes instead - px = np.zeros_like(hx) - py = np.zeros_like(hy) + px = np.zeros_like(hx, dtype=float) + py = np.zeros_like(hy, dtype=float) for i, (_hx, _hy) in enumerate(zip(hx, hy)): px[i] = unwrap_phase(np.angle(_hx)) py[i] = unwrap_phase(np.angle(_hy)) @@ -210,10 +210,10 @@ def run_pipeline(self, **pipeline_kws): copy=False) # Compute the frequencies that correspond to the frequencies of the # Fourier-transformed image. - fx = np.fft.fftfreq(rfft.shape[-1]).reshape(rfft.shape[0], -1, 1) - fy = np.fft.fftfreq(rfft.shape[-2]).reshape(rfft.shape[0], 1, -1) + fx = np.fft.fftfreq(rfft.shape[-2]).reshape(rfft.shape[0], -1, 1) + fy = np.fft.fftfreq(rfft.shape[-1]).reshape(rfft.shape[0], 1, -1) fxy = -2 * np.pi * 1j * (fx + 1j * fy) - fxy[0, 0] = 1 + fxy[:, 0, 0] = 1 # The wavefront is the real part of the inverse Fourier transform # of the filtered (divided by frequencies) data. diff --git a/tests/test_fourier_base.py b/tests/test_fourier_base.py index fbfe673..2f58ea0 100644 --- a/tests/test_fourier_base.py +++ b/tests/test_fourier_base.py @@ -137,7 +137,7 @@ def test_scale_to_filter_qlsi(): ifh = interfere.QLSInterferogram(image, **pipeline_kws) raw_wavefront = ifh.run_pipeline() - assert raw_wavefront.shape == (720, 720) + assert raw_wavefront.shape == (1, 720, 720) assert ifh.phase.shape == (1, 720, 720) assert ifh.amplitude.shape == (1, 720, 720) assert ifh.field.shape == (1, 720, 720) @@ -163,6 +163,6 @@ def test_scale_to_filter_qlsi(): ifr.run_pipeline(**pipeline_kws_scale) phase_scaled = unwrap_phase(ifh.phase - ifr.phase) - assert phase_scaled.shape == (126, 126) + assert phase_scaled.shape == (1, 126, 126) assert np.allclose(phase_scaled.mean(), 0.1257080793074251, atol=1e-6) diff --git a/tests/test_qlsi.py b/tests/test_qlsi.py index d151fb9..ff69fa3 100644 --- a/tests/test_qlsi.py +++ b/tests/test_qlsi.py @@ -118,8 +118,16 @@ def test_qlsi_rotate_2d_3d(hologram): axes=(-1, -2), # the y and x axes reshape=False, ) + rot_3d_2 = qpretrieve.interfere.if_qlsi.rotate_noreshape( + data_3d, + angle=2, + axes=(-2, -1), # the y and x axes + reshape=False, + ) + assert rot_2d.dtype == rot_3d.dtype assert np.array_equal(rot_2d, rot_3d[0]) + assert np.array_equal(rot_2d, rot_3d_2[0]) def test_qlsi_pad_2d_3d(hologram): @@ -134,4 +142,27 @@ def test_qlsi_pad_2d_3d(hologram): data_3d, ((0, 0), (sx // 2, sx // 2), (sy // 2, sy // 2)), mode="constant", constant_values=0) + assert gradpad_2d.dtype == gradpad_3d.dtype assert np.array_equal(gradpad_2d, gradpad_3d[0]) + + +def test_fxy_complex_mul(hologram): + data_2d = hologram + data_3d, _ = qpretrieve.data_input._convert_2d_to_3d(data_2d) + + assert np.array_equal(data_2d, data_3d[0]) + + # 2d + fx_2d = data_2d.reshape(-1, 1) + fy_2d = data_2d.reshape(1, -1) + fxy_2d = -2 * np.pi * 1j * (fx_2d + 1j * fy_2d) + fxy_2d[0, 0] = 1 + + # 3d + fx_3d = data_3d.reshape(data_3d.shape[0], -1, 1) + fy_3d = data_3d.reshape(data_3d.shape[0], 1, -1) + fxy_3d = -2 * np.pi * 1j * (fx_3d + 1j * fy_3d) + fxy_3d[:, 0, 0] = 1 + + assert np.array_equal(fx_2d, fx_3d[0]) + assert np.array_equal(fxy_2d, fxy_3d[0])