├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.md ├── LICENSE ├── Makefile ├── README.md ├── cpp ├── fdct2d_wrapper.cpp └── fdct3d_wrapper.cpp ├── curvelops ├── __init__.py ├── curvelops.py ├── plot │ ├── __init__.py │ ├── _curvelet.py │ └── _generic.py ├── typing │ ├── __init__.py │ └── _typing.py └── utils │ ├── __init__.py │ └── _utils.py ├── docs └── .nojekyll ├── docssrc ├── Makefile └── source │ ├── conf.py │ ├── contributing.rst │ ├── index.rst │ ├── installation.rst │ ├── modules.rst │ └── static │ ├── demo.png │ ├── logo.png │ └── reconstruction.png ├── examples ├── README.rst ├── plot_curvelets_in_fk.py ├── plot_seismic_regularization.py ├── plot_sigmoid.py ├── plot_sigmoid_coefficients.py ├── plot_sigmoid_disks.py └── plot_single_curvelet.py ├── notebooks ├── Desmystifying_Curvelets.ipynb └── Single_Curvelet_Interactive.ipynb ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── testdata ├── python.png ├── seismic.npz └── sigmoid.npz └── tests ├── __init__.py ├── test_fdct.py ├── test_fdct2d_wrapper.py ├── test_fdct3d_wrapper.py └── test_utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | curvelops/_version.py 2 | 3 | # Documentation 4 | docssrc/build 5 | docssrc/source/api/generated 6 | docssrc/source/gallery 7 | 8 | # Editors 9 | .*.sw[po] 10 | .vscode/ 11 | 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | pip-wheel-metadata/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | *.py,cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | db.sqlite3-journal 74 | 75 | # Flask stuff: 76 | instance/ 77 | .webassets-cache 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | 85 | # PyBuilder 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | .python-version 97 | 98 | # pipenv 99 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 100 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 101 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 102 | # install all needed dependencies. 103 | #Pipfile.lock 104 | 105 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 106 | __pypackages__/ 107 | 108 | # Celery stuff 109 | celerybeat-schedule 110 | celerybeat.pid 111 | 112 | # SageMath parsed files 113 | *.sage.py 114 | 115 | # Environments 116 | .env 117 | .venv 118 | env/ 119 | venv/ 120 | ENV/ 121 | env.bak/ 122 | venv.bak/ 123 | 124 | # Spyder project settings 125 | .spyderproject 126 | .spyproject 127 | 128 | # Rope project settings 129 | .ropeproject 130 | 131 | # mkdocs documentation 132 | /site 133 | 134 | # mypy 135 | .mypy_cache/ 136 | .dmypy.json 137 | dmypy.json 138 | 139 | # Pyre type checker 140 | .pyre/ 141 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: "^docs/" 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.3.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | 11 | - repo: https://github.com/psf/black 12 | rev: 23.9.1 13 | hooks: 14 | - id: black 15 | args: # arguments to configure black 16 | - --line-length=88 17 | 18 | - repo: https://github.com/pycqa/isort 19 | rev: 5.12.0 20 | hooks: 21 | - id: isort 22 | name: isort (python) 23 | args: 24 | [ 25 | "--profile", 26 | "black", 27 | "--skip", 28 | "__init__.py", 29 | "--filter-files", 30 | "--line-length=88", 31 | ] 32 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | Carlos Alberto da Costa Filho, [@cako](https://github.com/cako) 2 | Matteo Ravasi, [@mrava87](https://github.com/mrava87) 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2023 Carlos da Costa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PIP := $(shell command -v pip3 2> /dev/null || command which pip 2> /dev/null) 2 | PYTHON := $(shell command -v python3 2> /dev/null || command which python 2> /dev/null) 3 | PYTEST := $(shell command -v pytest 2> /dev/null) 4 | 5 | .PHONY: install dev-install tests doc watchdoc servedoc lint typeannot coverage 6 | 7 | pipcheck: 8 | ifndef PIP 9 | $(error "Ensure pip or pip3 are in your PATH") 10 | endif 11 | @echo Using pip: $(PIP) 12 | 13 | pythoncheck: 14 | ifndef PYTHON 15 | $(error "Ensure python or python3 are in your PATH") 16 | endif 17 | @echo Using python: $(PYTHON) 18 | 19 | pytestcheck: 20 | ifndef PYTEST 21 | $(error "Ensure pytest is in your PATH") 22 | endif 23 | @echo Using pytest: $(PYTEST) 24 | 25 | install: 26 | make pipcheck 27 | $(PIP) install -r requirements.txt && $(PIP) install . 28 | 29 | dev-install: 30 | make pipcheck 31 | $(PIP) install -r requirements-dev.txt && $(PIP) install -e . 32 | 33 | tests: 34 | make pytestcheck 35 | $(PYTEST) tests 36 | 37 | lint: 38 | flake8 examples/ docs/ curvelops/ tests/ 39 | 40 | typeannot: 41 | mypy curvelops/ examples/ 42 | 43 | coverage: 44 | coverage run -m pytest && coverage xml && coverage html && $(PYTHON) -m http.server --directory htmlcov/ 45 | 46 | watchdoc: 47 | make doc && while inotifywait -q -r curvelops/ examples/ docssrc/source/ -e create,delete,modify; do { make docupdate; }; done 48 | 49 | servedoc: 50 | $(PYTHON) -m http.server --directory docssrc/build/html/ 51 | 52 | doc: 53 | # Add after rm: sphinx-apidoc -f -M -o source/ ../curvelops 54 | # Use -O to include private files 55 | cd docssrc && rm -rf source/api/generated && rm -rf source/gallery &&\ 56 | rm -rf source/tutorials && rm -rf source/examples &&\ 57 | rm -rf build && make html && cd .. 58 | 59 | docupdate: 60 | cd docssrc && make html && cd .. 61 | 62 | docgithub: 63 | cd docssrc && make github && cd .. 64 | 65 | docpush: 66 | # Only run when main is at a release commit/tag 67 | python3 -m pip install git+https://github.com/PyLops/curvelops@`git describe --tags` 68 | git checkout gh-pages && git merge main && cd docssrc && make github &&\ 69 | cd ../docs && git add . && git commit -m "Updated documentation" &&\ 70 | git push origin gh-pages && git checkout main 71 | python3 -m pip install -e . 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Documentation](https://github.com/PyLops/curvelops/actions/workflows/pages/pages-build-deployment/badge.svg?branch=gh-pages)](https://pylops.github.io/curvelops/) 2 | [![Slack Status](https://img.shields.io/badge/chat-slack-green.svg)](https://pylops.slack.com) 3 | 4 | # curvelops 5 | 6 | Python wrapper for [CurveLab](http://www.curvelet.org)'s 2D and 3D curvelet 7 | transforms. It uses the [PyLops](https://pylops.readthedocs.io/) design 8 | framework to provide the forward and inverse curvelet transforms as matrix-free 9 | linear operations. If you are still confused, check out 10 | [some examples](https://github.com/PyLops/curvelops/tree/main/examples) below 11 | or the [PyLops website](https://pylops.readthedocs.io/)! 12 | 13 | ## Installation 14 | 15 | Installing `curvelops` requires the following external components: 16 | 17 | - [FFTW](http://www.fftw.org/download.html) 2.1.5 18 | - [CurveLab](http://curvelet.org/software.html) >= 2.0.2 19 | 20 | Both of these packages _must be installed manually_. See more information in 21 | the [Documentation](https://pylops.github.io/curvelops/installation.html#requirements). 22 | After these are installed, you may install `curvelops` with: 23 | 24 | ```bash 25 | export FFTW=/path/to/fftw-2.1.5 26 | export FDCT=/path/to/CurveLab-2.1.3 27 | python3 -m pip install git+https://github.com/PyLops/curvelops@0.23.4 28 | ``` 29 | 30 | as long as you are using a `pip>=10.0`. To check, run `python3 -m pip --version`. 31 | 32 | ## Getting Started 33 | 34 | For a 2D transform, you can get started with: 35 | 36 | ```python 37 | import numpy as np 38 | import curvelops as cl 39 | 40 | x = np.random.randn(100, 50) 41 | FDCT = cl.FDCT2D(dims=x.shape) 42 | c = FDCT @ x 43 | xinv = FDCT.H @ c 44 | np.testing.assert_allclose(x, xinv) 45 | ``` 46 | 47 | An excellent place to see how to use the library is the 48 | [Gallery](https://pylops.github.io/curvelops/gallery/index.html). You can also 49 | find more examples in the 50 | [`notebooks/`](https://github.com/PyLops/curvelops/tree/main/notebooks) folder. 51 | 52 | ![Demo](https://github.com/PyLops/curvelops/raw/main/docssrc/source/static/demo.png) 53 | ![Reconstruction](https://github.com/PyLops/curvelops/raw/main/docssrc/source/static/reconstruction.png) 54 | 55 | ## Useful links 56 | 57 | * [Paul Goyes](https://github.com/PAULGOYES) has kindly contributed a rundown of how to install curvelops: [link to YouTube video (in Spanish)](https://www.youtube.com/watch?v=LAFkknyOpGY). 58 | 59 | ## Note 60 | 61 | This package contains no CurveLab code apart from function calls. It is 62 | provided to simplify the use of CurveLab in a Python environment. Please ensure 63 | you own a CurveLab license as per required by the authors. See the 64 | [CurveLab website](http://curvelet.org/software.html) for more information. All 65 | CurveLab rights are reserved to Emmanuel Candes, Laurent Demanet, David Donoho 66 | and Lexing Ying. 67 | -------------------------------------------------------------------------------- /cpp/fdct2d_wrapper.cpp: -------------------------------------------------------------------------------- 1 | /* fdct2d_wrapper (Pybind11 wrapper for Fast 2D Curvelet Wrapping Transform) 2 | Copyright (C) 2020-2023 Carlos Alberto da Costa Filho 3 | 4 | ${CXX} -O3 -Wall -shared -std=c++11 -fPIC \ 5 | -I${FFTW}/fftw `python3 -m pybind11 --includes` \ 6 | fdct2d_wrapper.cpp ${FDCT}/fdct_wrapping_cpp/src/libfdct_wrapping.a \ 7 | -L${FFTW}/fftw/.libs -lfftw \ 8 | -o fdct2d_wrapper`python3-config --extension-suffix` 9 | */ 10 | 11 | #include "fdct_wrapping.hpp" 12 | #include "fdct_wrapping_inc.hpp" 13 | #include "fdct_wrapping_inline.hpp" 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | #include 20 | namespace py = pybind11; 21 | namespace fdct = fdct_wrapping_ns; 22 | using fdct_wrapping_ns::cpx; 23 | using fdct_wrapping_ns::CpxNumMat; 24 | using std::vector; 25 | 26 | py::tuple 27 | fdct2d_param_wrap(int m, int n, int nbscales, int nbangles_coarse, int ac) 28 | { 29 | // Almost sure this function creates a copy, but it's ok since the outputs 30 | // are small 31 | vector> sx, sy; 32 | vector> fx, fy; 33 | vector> nx, ny; 34 | fdct::fdct_wrapping_param(m, n, nbscales, nbangles_coarse, ac, sx, sy, fx, 35 | fy, nx, ny); 36 | return py::make_tuple(sx, sy, fx, fy, nx, ny); 37 | } 38 | 39 | vector>> 40 | fdct2d_forward_wrap(int nbscales, 41 | int nbangles_coarse, 42 | int ac, 43 | py::array_t x) 44 | { 45 | // Our wrapper takes a NumPy array, but ``fdct_wrapping`` requires a 46 | // CpxNumMat input (which will be accessed read-only). So we must create 47 | // CpxNumMat ``xmat`` which will "mirror" our input ``x`` in a no-copy 48 | // fashion. We also need to output ``c`` whose conversion to a Python list 49 | // of lists of CpxNumMat will be handled by pybind11. The vector -> list 50 | // casting is automatic in pybind11, whereas the CpxNumMat -> 51 | // py::array_t casting is inside our function. 52 | CpxNumMat xmat; 53 | vector> cmat; 54 | 55 | // Responsibly access py::array with possible casting to complex. See: 56 | // https://stackoverflow.com/questions/42645228/cast-numpy-array-to-from-custom-c-matrix-class-using-pybind11 57 | // Note: CurveLab uses Fortran-style indexing, so we must transpose the 58 | // input array. We do this 59 | // by simply reading it as a Fortran array 60 | auto buf = 61 | py::array_t::ensure(x); 62 | if (!buf) 63 | throw std::runtime_error("x array buffer is empty. If you're calling " 64 | "from Python this should not happen!"); 65 | if (buf.ndim() != 2) 66 | throw std::runtime_error("x.ndims != 2"); 67 | 68 | // We don't to initialize ``x(m, n)`` because this allocates an array on 69 | // the heap! 70 | xmat._m = buf.shape()[0]; 71 | xmat._n = buf.shape()[1]; 72 | // Put our Python array buffer pointer as the CpxNumMat data 73 | xmat._data = const_cast(buf.data()); 74 | 75 | // Call our forward function with all the right types 76 | fdct::fdct_wrapping(xmat._m, xmat._n, nbscales, nbangles_coarse, ac, xmat, 77 | cmat); 78 | 79 | // Clear the structure as if it had never existed... 80 | // xmat didn't allocate any data, so we make sure it doesn't deallocate any 81 | // on the way out 82 | xmat._m = xmat._n = 0; 83 | xmat._data = nullptr; 84 | 85 | vector>> c; 86 | // Expand ``c`` to fit the scales 87 | c.resize(cmat.size()); 88 | for (size_t i = 0; i < cmat.size(); i++) { 89 | // Now we expand each scale to fit the angles 90 | c[i].resize(cmat[i].size()); 91 | for (size_t j = 0; j < cmat[i].size(); j++) { 92 | // Create capsule linked to `cmat[i][j]._data` to track its 93 | // lifetime 94 | // https://stackoverflow.com/questions/44659924/returning-numpy-arrays-via-pybind11 95 | py::capsule free_when_done(cmat[i][j].data(), [](void* cpx_ptr) { 96 | cpx* cpx_arr = reinterpret_cast(cpx_ptr); 97 | delete[] cpx_arr; 98 | }); 99 | 100 | // Shape 101 | // Strides (in bytes) of the underlying data array 102 | // Data pointer 103 | // Capsule to be called when the array is deleted in Python 104 | py::array c_arr({cmat[i][j]._n, cmat[i][j]._m}, 105 | {sizeof(cpx) * cmat[i][j]._m, sizeof(cpx)}, 106 | cmat[i][j].data(), free_when_done); 107 | 108 | c[i][j] = c_arr; 109 | cmat[i][j]._m = cmat[i][j]._n = 0; 110 | cmat[i][j]._data = nullptr; 111 | } 112 | } 113 | return c; 114 | } 115 | 116 | py::array_t 117 | fdct2d_inverse_wrap(int m, 118 | int n, 119 | int nbscales, 120 | int nbangles_coarse, 121 | int ac, 122 | vector>> c) 123 | { 124 | // Similarly to the forward wrapper, we create ``cmat`` and ``xmat`` to use 125 | // as dummy input and output arrays. 126 | size_t i, j; 127 | CpxNumMat xmat; 128 | vector> cmat; 129 | 130 | if ((size_t)nbscales != c.size()) 131 | throw std::runtime_error("nbscales != len(c)"); 132 | 133 | // We copy the ``c`` "structure" onto a ``cmat`` "structure" 134 | // Start by expanding the first index of ``cmat`` to fit all scales 135 | cmat.resize(c.size()); 136 | for (i = 0; i < c.size(); i++) { 137 | // Now we expand each scale to fit all angles for that scale 138 | cmat[i].resize(c[i].size()); 139 | for (j = 0; j < c[i].size(); j++) { 140 | // Now we must copy the structure over to ``cmat`` 141 | py::buffer_info buf = c[i][j].request(); 142 | cmat[i][j]._m = buf.shape[1]; 143 | cmat[i][j]._n = buf.shape[0]; 144 | cmat[i][j]._data = static_cast(buf.ptr); 145 | } 146 | } 147 | // No bounds checking is made inside this, so if ``c`` (or equivalently 148 | // ``cmat``) are not compatible with the other parameters, this function 149 | // WILL segfault 150 | // TODO: Optionally sanitize this by calling ``fdct2d_param_wrap`` 151 | fdct::ifdct_wrapping(m, n, nbscales, nbangles_coarse, ac, cmat, xmat); 152 | 153 | // Clear input structure without deallocating 154 | for (i = 0; i < c.size(); i++) 155 | for (j = 0; j < c[i].size(); j++) { 156 | cmat[i][j]._m = cmat[i][j]._n = 0; 157 | cmat[i][j]._data = nullptr; 158 | } 159 | 160 | py::capsule free_when_done(xmat.data(), [](void* cpx_ptr) { 161 | cpx* cpx_arr = reinterpret_cast(cpx_ptr); 162 | delete[] cpx_arr; 163 | }); 164 | 165 | // Create output array 166 | // Shape 167 | // Strides (in bytes) of the underlying data array 168 | // Data pointer 169 | // Capsule to be called when the array is deleted in Python 170 | py::array x({m, n}, {sizeof(cpx), sizeof(cpx) * m}, xmat.data(), 171 | free_when_done); 172 | 173 | // Clear output structure without deallocating 174 | xmat._m = xmat._n = 0; 175 | xmat._data = nullptr; 176 | 177 | return x; 178 | } 179 | 180 | PYBIND11_MODULE(fdct2d_wrapper, m) 181 | { 182 | m.doc() = "FDCT2D pybind11 wrapper"; 183 | m.def("fdct2d_param_wrap", 184 | &fdct2d_param_wrap, 185 | "Parameters for 2D FDCT", 186 | py::return_value_policy::take_ownership); 187 | m.def("fdct2d_forward_wrap", 188 | &fdct2d_forward_wrap, 189 | "2D Forward FDCT", 190 | py::return_value_policy::take_ownership); 191 | m.def("fdct2d_inverse_wrap", 192 | &fdct2d_inverse_wrap, 193 | "2D Inverse FDCT", 194 | py::return_value_policy::take_ownership); 195 | } 196 | -------------------------------------------------------------------------------- /cpp/fdct3d_wrapper.cpp: -------------------------------------------------------------------------------- 1 | /* fdct3d_wrapper (Pybind11 wrapper for Fast 3D Curvelet Wrapping Transform) 2 | Copyright (C) 2020-2023 Carlos Alberto da Costa Filho 3 | 4 | ${CXX} -O3 -Wall -shared -std=c++11 -fPIC \ 5 | -I${FFTW}/fftw `python3 -m pybind11 --includes` \ 6 | fdct2d_wrapper.cpp ${FDCT}/fdct3d/src/libfdct3d.a \ 7 | -L${FFTW}/fftw/.libs -lfftw \ 8 | -o fdct2d_wrapper`python3-config --extension-suffix` 9 | */ 10 | 11 | #include "fdct3d.hpp" 12 | #include "fdct3dinline.hpp" 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | namespace py = pybind11; 20 | 21 | py::tuple 22 | fdct3d_param_wrap(int m, 23 | int n, 24 | int p, 25 | int nbscales, 26 | int nbangles_coarse, 27 | int ac) 28 | { 29 | // Almost sure this function creates a copy, but it's ok since the outputs 30 | // are small 31 | vector> fxs, fys, fzs; 32 | vector> nxs, nys, nzs; 33 | fdct3d_param( 34 | m, n, p, nbscales, nbangles_coarse, ac, fxs, fys, fzs, nxs, nys, nzs); 35 | return py::make_tuple(fxs, fys, fzs, nxs, nys, nzs); 36 | } 37 | 38 | vector>> 39 | fdct3d_forward_wrap(int nbscales, 40 | int nbangles_coarse, 41 | int ac, 42 | py::array_t x) 43 | { 44 | // Our wrapper takes a NumPy array, but ``fdct3d_forward`` requires a 45 | // CpxNumTns input (which will be accessed read-only). So we must create 46 | // CpxNumTns ``xtns`` which will "mirror" our input ``x`` in a no-copy 47 | // fashion. We also need to output ``c`` whose conversion to a Python list 48 | // of lists of CpxNumTns will be handled by pybind11. The vector -> list 49 | // casting is automatic in pybind11, whereas the CpxNumTns -> 50 | // py::array_t casting is inside our function. 51 | CpxNumTns xtns; 52 | vector> ctns; 53 | 54 | // Responsibly access py::array with possible casting to complex. See: 55 | // https://stackoverflow.com/questions/42645228/cast-numpy-array-to-from-custom-c-matrix-class-using-pybind11 56 | // Note: CurveLab uses Fortran-style indexing, so we must transpose the 57 | // input array. We do this 58 | // by simply reading it as a Fortran array 59 | auto buf = 60 | py::array_t::ensure(x); 61 | if (!buf) 62 | throw std::runtime_error("x array buffer is empty. If you're calling " 63 | "from Python this should not happen!"); 64 | if (buf.ndim() != 3) 65 | throw std::runtime_error("x.ndims != 3"); 66 | 67 | // We don't to initialize ``x(m, n, p)`` because this allocates an array on 68 | // the heap! 69 | xtns._m = buf.shape()[0]; 70 | xtns._n = buf.shape()[1]; 71 | xtns._p = buf.shape()[2]; 72 | // Put our Python array buffer pointer as the CpxNumTns data 73 | xtns._data = const_cast(buf.data()); 74 | 75 | // Call our forward function with all the right types 76 | fdct3d_forward( 77 | xtns._m, xtns._n, xtns._p, nbscales, nbangles_coarse, ac, xtns, ctns); 78 | 79 | // Clear the structure as if it had never existed... 80 | // xtns didn't allocate any data, so we make sure it doesn't deallocate any 81 | // on the way out 82 | xtns._m = xtns._n = xtns._p = 0; 83 | xtns._data = nullptr; 84 | 85 | vector>> c; 86 | // Expand ``c`` to fit the scales 87 | c.resize(ctns.size()); 88 | for (size_t i = 0; i < ctns.size(); i++) { 89 | // Now we expand each scale to fit the angles 90 | c[i].resize(ctns[i].size()); 91 | for (size_t j = 0; j < ctns[i].size(); j++) { 92 | // Create capsule linked to `ctns[i][j]._data` to track its 93 | // lifetime 94 | // https://stackoverflow.com/questions/44659924/returning-numpy-arrays-via-pybind11 95 | py::capsule free_when_done(ctns[i][j].data(), [](void* cpx_ptr) { 96 | cpx* cpx_arr = reinterpret_cast(cpx_ptr); 97 | delete[] cpx_arr; 98 | }); 99 | 100 | // Shape 101 | // Strides (in bytes) of the underlying data array 102 | // Data pointer 103 | // Capsule to be called when the array is deleted in Python 104 | py::array c_arr({ctns[i][j]._p, ctns[i][j]._n, ctns[i][j]._m}, 105 | {sizeof(cpx) * ctns[i][j]._m * ctns[i][j]._n, 106 | sizeof(cpx) * ctns[i][j]._m, sizeof(cpx)}, 107 | ctns[i][j].data(), free_when_done); 108 | 109 | c[i][j] = c_arr; 110 | ctns[i][j]._m = ctns[i][j]._n = ctns[i][j]._p = 0; 111 | ctns[i][j]._data = nullptr; 112 | } 113 | } 114 | return c; 115 | } 116 | 117 | py::array_t 118 | fdct3d_inverse_wrap(int m, 119 | int n, 120 | int p, 121 | int nbscales, 122 | int nbangles_coarse, 123 | int ac, 124 | vector>> c) 125 | { 126 | // Similarly to the forward wrapper, we create ``ctns`` and ``xtns`` to use 127 | // as dummy input and output arrays. 128 | size_t i, j; 129 | CpxNumTns xtns; 130 | vector> ctns; 131 | 132 | if ((size_t)nbscales != c.size()) 133 | throw std::runtime_error("nbscales != len(c)"); 134 | 135 | // We copy the ``c`` "structure" onto a ``ctns`` "structure" 136 | // Start by expanding the first index of ``ctns`` to fit all scales 137 | ctns.resize(c.size()); 138 | for (i = 0; i < c.size(); i++) { 139 | // Now we expand each scale to fit all angles for that scale 140 | ctns[i].resize(c[i].size()); 141 | for (j = 0; j < c[i].size(); j++) { 142 | // Now we must copy the structure over to ``ctns`` 143 | py::buffer_info buf = c[i][j].request(); 144 | ctns[i][j]._m = buf.shape[2]; 145 | ctns[i][j]._n = buf.shape[1]; 146 | ctns[i][j]._p = buf.shape[0]; 147 | ctns[i][j]._data = static_cast(buf.ptr); 148 | } 149 | } 150 | // No bounds checking is made inside this, so if ``c`` (or equivalently 151 | // ``ctns``) are not compatible with the other parameters, this function 152 | // WILL segfault 153 | // TODO: Optionally sanitize this by calling ``fdct3d_param_wrap`` 154 | fdct3d_inverse(m, n, p, nbscales, nbangles_coarse, ac, ctns, xtns); 155 | 156 | // Clear input structure without deallocating 157 | for (i = 0; i < c.size(); i++) 158 | for (j = 0; j < c[i].size(); j++) { 159 | ctns[i][j]._m = ctns[i][j]._n = ctns[i][j]._p = 0; 160 | ctns[i][j]._data = nullptr; 161 | } 162 | 163 | py::capsule free_when_done(xtns.data(), [](void* cpx_ptr) { 164 | cpx* cpx_arr = reinterpret_cast(cpx_ptr); 165 | delete[] cpx_arr; 166 | }); 167 | 168 | // Create output array 169 | // Shape 170 | // Strides (in bytes) of the underlying data array 171 | // Data pointer 172 | // Capsule to be called when the array is deleted in Python 173 | py::array x({m, n, p}, {sizeof(cpx), sizeof(cpx) * m, sizeof(cpx) * m * n}, 174 | xtns.data(), free_when_done); 175 | 176 | // Clear output structure without deallocating 177 | xtns._m = xtns._n = xtns._p = 0; 178 | xtns._data = nullptr; 179 | 180 | return x; 181 | } 182 | 183 | PYBIND11_MODULE(fdct3d_wrapper, m) 184 | { 185 | m.doc() = "FDCT3D pybind11 wrapper"; 186 | m.def("fdct3d_param_wrap", 187 | &fdct3d_param_wrap, 188 | "Parameters for 3D FDCT", 189 | py::return_value_policy::take_ownership); 190 | m.def("fdct3d_forward_wrap", 191 | &fdct3d_forward_wrap, 192 | "3D Forward FDCT", 193 | py::return_value_policy::take_ownership); 194 | m.def("fdct3d_inverse_wrap", 195 | &fdct3d_inverse_wrap, 196 | "3D Inverse FDCT", 197 | py::return_value_policy::take_ownership); 198 | } 199 | -------------------------------------------------------------------------------- /curvelops/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ``curvelops`` 3 | ============= 4 | 5 | Python wrapper for CurveLab's 2D and 3D curvelet transforms. 6 | """ 7 | from .curvelops import * 8 | from .utils import * 9 | from .plot import * 10 | from .typing import * 11 | 12 | 13 | try: 14 | from ._version import __version__ 15 | except ImportError: 16 | from datetime import datetime 17 | 18 | __version__ = "0.0.unknown+" + datetime.today().strftime("%Y%m%d") 19 | -------------------------------------------------------------------------------- /curvelops/curvelops.py: -------------------------------------------------------------------------------- 1 | """ 2 | ``curvelops.curvelops`` 3 | ======================= 4 | 5 | Provides a LinearOperator for the 2D and 3D curvelet transforms. 6 | """ 7 | 8 | from itertools import product 9 | from math import prod 10 | from typing import Callable, Optional, Tuple, Union 11 | 12 | import numpy as np 13 | from numpy.core.multiarray import normalize_axis_index # type: ignore 14 | from numpy.typing import DTypeLike, NDArray 15 | from pylops import LinearOperator 16 | from pylops.utils.typing import InputDimsLike 17 | 18 | # pylint: disable=no-name-in-module 19 | from .fdct2d_wrapper import ( # noqa: F403 20 | fdct2d_forward_wrap, 21 | fdct2d_inverse_wrap, 22 | fdct2d_param_wrap, 23 | ) 24 | from .fdct3d_wrapper import ( # noqa: F403 25 | fdct3d_forward_wrap, 26 | fdct3d_inverse_wrap, 27 | fdct3d_param_wrap, 28 | ) 29 | 30 | # pylint: enable=no-name-in-module 31 | from .typing import FDCTStructLike 32 | 33 | 34 | def _fdct_docs(dimension: int) -> str: 35 | if dimension == 2: 36 | doc = "2D" 37 | elif dimension == 3: 38 | doc = "3D" 39 | else: 40 | doc = "2D/3D" 41 | return f"""{doc} dimensional Curvelet operator. 42 | Apply {doc} Curvelet Transform along two ``axes`` of a 43 | multi-dimensional array of size ``dims``. 44 | 45 | Parameters 46 | ---------- 47 | dims : :obj:`tuple` 48 | Number of samples for each dimension. 49 | axes : :obj:`tuple`, optional 50 | Axes along which FDCT is applied. 51 | nbscales : :obj:`int`, optional 52 | Number of scales (including the coarsest level); 53 | Defaults to ceil(log2(min(input_dims)) - 3). 54 | nbangles_coarse : :obj:`int`, optional 55 | Number of angles at 2nd coarsest scale. 56 | allcurvelets : :obj:`bool`, optional 57 | Use curvelets at the finest (last) scale. 58 | If ``False``, a wavelet transform will be used for the 59 | finest scale. The coarsest scale is always a wavelet transform; 60 | the ones between the coarsest and the finest are all curvelet 61 | transforms. This option only affects the finest scale. 62 | dtype : :obj:`DTypeLike `, optional 63 | ``dtype`` of the transform. 64 | """ 65 | 66 | 67 | class FDCT(LinearOperator): 68 | __doc__ = _fdct_docs(0) 69 | 70 | def __init__( 71 | self, 72 | dims: InputDimsLike, 73 | axes: Tuple[int, ...], 74 | nbscales: Optional[int] = None, 75 | nbangles_coarse: int = 16, 76 | allcurvelets: bool = True, 77 | dtype: DTypeLike = "complex128", 78 | ) -> None: 79 | ndim = len(dims) 80 | 81 | # Ensure axes are between 0, ndim-1 82 | axes = tuple(normalize_axis_index(d, ndim) for d in axes) 83 | 84 | # If input is shaped (100, 200, 300) and axes = (0, 2) 85 | # then input_shape will be (100, 300) 86 | self._input_shape = list(int(dims[d]) for d in axes) 87 | if nbscales is None: 88 | nbscales = int(np.ceil(np.log2(min(self._input_shape)) - 3)) 89 | 90 | # Check dimension 91 | sizes: Union[Tuple[NDArray, NDArray], Tuple[NDArray, NDArray, NDArray]] 92 | if len(axes) == 2: 93 | self.fdct: Callable = fdct2d_forward_wrap # type: ignore # noqa: F405 94 | self.ifdct: Callable = fdct2d_inverse_wrap # type: ignore # noqa: F405 95 | _, _, _, _, nxs, nys = fdct2d_param_wrap( # type: ignore # noqa: F405 96 | *self._input_shape, nbscales, nbangles_coarse, allcurvelets 97 | ) 98 | sizes = (nys, nxs) 99 | elif len(axes) == 3: 100 | self.fdct: Callable = fdct3d_forward_wrap # type: ignore # noqa: F405 101 | self.ifdct: Callable = fdct3d_inverse_wrap # type: ignore # noqa: F405 102 | _, _, _, nxs, nys, nzs = fdct3d_param_wrap( # type: ignore # noqa: F405 103 | *self._input_shape, nbscales, nbangles_coarse, allcurvelets 104 | ) 105 | sizes = (nzs, nys, nxs) 106 | else: 107 | raise NotImplementedError("FDCT is only implemented in 2D or 3D") 108 | 109 | # Complex operator is required to handle complex input 110 | dtype = np.dtype(dtype) 111 | if np.issubdtype(dtype, np.complexfloating): 112 | cpx = True 113 | else: 114 | cpx = False 115 | raise NotImplementedError("Only complex types supported") 116 | 117 | # Now we need to build the iterator which will only iterate along 118 | # the required axes. Following the example above, 119 | # iterable_axes = [ False, True, False ] 120 | iterable_axes = [i not in axes for i in range(ndim)] 121 | iterable_dims = np.array(dims)[iterable_axes] 122 | self._ndim_iterable = prod(iterable_dims) 123 | 124 | # Build the iterator itself. In our example, the slices 125 | # would be [:, i, :] for i in range(200) 126 | # We use slice(None) is the colon operator 127 | self._iterator = list( 128 | product( 129 | *( 130 | range(dims[ax]) if doiter else [slice(None)] 131 | for ax, doiter in enumerate(iterable_axes) 132 | ) 133 | ) 134 | ) 135 | 136 | # For a single 2d/3d input, the length of the vector will be given by 137 | # the shapes in FDCT.sizes 138 | self.shapes = [ 139 | [tuple(s[i][j] for s in sizes) for j in range(len(nx))] 140 | for i, nx in enumerate(nxs) 141 | ] 142 | self._output_len = sum(prod(j) for i in self.shapes for j in i) 143 | 144 | # Save some useful properties 145 | self.inpdims = dims 146 | self.axes = axes 147 | self.nbscales = nbscales 148 | self.nbangles_coarse = nbangles_coarse 149 | self.allcurvelets = allcurvelets 150 | self.cpx = cpx 151 | 152 | # Required by PyLops 153 | super().__init__( 154 | dtype=dtype, 155 | dims=self.inpdims, 156 | dimsd=(*iterable_dims, self._output_len), 157 | ) 158 | 159 | def _matvec(self, x: NDArray) -> NDArray: 160 | fwd_out = np.zeros((self._output_len, self._ndim_iterable), dtype=self.dtype) 161 | for i, index in enumerate(self._iterator): 162 | x_shaped = np.array(x.reshape(self.inpdims)[index]) 163 | c_struct: FDCTStructLike = self.fdct( 164 | self.nbscales, 165 | self.nbangles_coarse, 166 | self.allcurvelets, 167 | x_shaped, 168 | ) 169 | fwd_out[:, i] = self.vect(c_struct) 170 | return fwd_out.ravel() 171 | 172 | def _rmatvec(self, x: NDArray) -> NDArray: 173 | y_shaped = x.reshape(self._output_len, self._ndim_iterable) 174 | inv_out = np.zeros(self.inpdims, dtype=self.dtype) 175 | for i, index in enumerate(self._iterator): 176 | y_struct = self.struct(np.array(y_shaped[:, i])) 177 | xinv: NDArray = self.ifdct( 178 | *self._input_shape, 179 | self.nbscales, 180 | self.nbangles_coarse, 181 | self.allcurvelets, 182 | y_struct, 183 | ) 184 | inv_out[index] = xinv 185 | 186 | return inv_out.ravel() 187 | 188 | def inverse(self, x: NDArray) -> NDArray: 189 | """Inverse Curvelet Transform 190 | 191 | Parameters 192 | ---------- 193 | x : NDArray 194 | Input vector 195 | 196 | Returns 197 | ------- 198 | NDArray 199 | FDCT.H @ x 200 | """ 201 | return self._rmatvec(x) 202 | 203 | def struct(self, x: NDArray) -> FDCTStructLike: 204 | """Convert curvelet flattened vector to curvelet structure. 205 | 206 | The FDCT always returns a 1D vector that has all curvelet 207 | coefficients. These coefficients can be organized into 208 | scales, wedges and spatial positions. Applying this 209 | function to a 1D vector generates this structure. 210 | 211 | Parameters 212 | ---------- 213 | x : :obj:`NDArray ` 214 | Input flattened vector. 215 | 216 | Returns 217 | ------- 218 | :obj:`FDCTStructLike ` 219 | Curvelet structure, a list of lists of multidimensional arrays. 220 | The first index corresponds to scale, the second corresponds to 221 | angular wedge. 222 | """ 223 | c_struct: FDCTStructLike = [] 224 | k = 0 225 | for shapes_s in self.shapes: 226 | angles = [] 227 | for shape_w in shapes_s: 228 | size = prod(shape_w) 229 | angles.append(x[k : k + size].reshape(shape_w)) 230 | k += size 231 | c_struct.append(angles) 232 | return c_struct 233 | 234 | def vect(self, x: FDCTStructLike) -> NDArray: 235 | """Convert curvelet structure to curvelet flattened vector. 236 | 237 | The FDCT always returns a 1D vector that has all curvelet 238 | coefficients. These coefficients can be organized into 239 | scales, wedges and spatial positions. Applying this 240 | function to a curvelet structure returns the flattened 241 | vector. 242 | 243 | Parameters 244 | ---------- 245 | x : :obj:`FDCTStructLike ` 246 | Input curvelet structure. 247 | 248 | Returns 249 | ------- 250 | :obj:`NDArray ` 251 | Flattened vector. 252 | """ 253 | return np.concatenate([coef.ravel() for angle in x for coef in angle]) 254 | 255 | 256 | class FDCT2D(FDCT): 257 | __doc__ = _fdct_docs(2) 258 | 259 | def __init__( 260 | self, 261 | dims: InputDimsLike, 262 | axes: Tuple[int, ...] = (-2, -1), 263 | nbscales: Optional[int] = None, 264 | nbangles_coarse: int = 16, 265 | allcurvelets: bool = True, 266 | dtype: DTypeLike = "complex128", 267 | ) -> None: 268 | assert len(axes) == 2, ValueError("FDCT2D must be called with exactly two axes") 269 | super().__init__(dims, axes, nbscales, nbangles_coarse, allcurvelets, dtype) 270 | 271 | 272 | class FDCT3D(FDCT): 273 | __doc__ = _fdct_docs(3) 274 | 275 | def __init__( 276 | self, 277 | dims: InputDimsLike, 278 | axes: Tuple[int, ...] = (-3, -2, -1), 279 | nbscales: Optional[int] = None, 280 | nbangles_coarse: int = 16, 281 | allcurvelets: bool = True, 282 | dtype: DTypeLike = "complex128", 283 | ) -> None: 284 | assert len(axes) == 3, ValueError( 285 | "FDCT3D must be called with exactly three axes" 286 | ) 287 | super().__init__(dims, axes, nbscales, nbangles_coarse, allcurvelets, dtype) 288 | -------------------------------------------------------------------------------- /curvelops/plot/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ``curvelops.plot`` 3 | ================== 4 | 5 | Auxiliary functions for plotting. 6 | """ 7 | 8 | from . import _curvelet, _generic 9 | 10 | __all__ = _curvelet.__all__ + _generic.__all__ 11 | 12 | 13 | from ._curvelet import * 14 | from ._generic import * 15 | -------------------------------------------------------------------------------- /curvelops/plot/_curvelet.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "curveshow", 3 | "overlay_disks", 4 | ] 5 | import itertools 6 | from math import ceil, floor 7 | from typing import List, Optional, Union 8 | 9 | import matplotlib as mpl 10 | import matplotlib.cm as cm 11 | import matplotlib.pyplot as plt 12 | import numpy as np 13 | from matplotlib.colors import Colormap 14 | from matplotlib.figure import Figure 15 | from numpy.typing import NDArray 16 | 17 | from ..typing import FDCTStructLike 18 | from ..utils import apply_along_wedges, energy_split 19 | 20 | 21 | def curveshow( 22 | c_struct: FDCTStructLike, 23 | k_space: bool = False, 24 | basesize: int = 5, 25 | showaxis: bool = False, 26 | real: bool = True, 27 | kwargs_imshow: Optional[dict] = None, 28 | ) -> List[Figure]: 29 | """Display curvelet coefficients in each wedge as images. 30 | 31 | For each curvelet scale, display a figure with each wedge 32 | plotted as an image in its own axis. 33 | 34 | Parameters 35 | ---------- 36 | c_struct : :obj:`FDCTStructLike ` 37 | Curvelet structure. 38 | k_space : :obj:`bool`, optional 39 | Show curvelet coefficient (False) or its 2D FFT transform (True), 40 | by default False. 41 | basesize : :obj:`int`, optional 42 | Base fize of figure, by default 5. Each figure will be sized 43 | ``(basesize * cols, basesize * rows)``, where 44 | ``rows = floor(sqrt(nangles))`` and ``cols = ceil(nangles / rows)`` 45 | showaxis : :obj:`bool`, optional 46 | Turn on axis lines and labels, by default False. 47 | real : :obj:`bool`, optional 48 | Plot real or imaginary part of curvelet coefficients. Only applicable 49 | when ``k_space`` is False. 50 | kwargs_imshow : ``Optional[dict]``, optional 51 | Arguments to be passed to :obj:`matplotlib.pyplot.imshow`. 52 | 53 | Examples 54 | -------- 55 | >>> import numpy as np 56 | >>> from curvelops import FDCT2D 57 | >>> from curvelops.utils import apply_along_wedges, energy 58 | >>> from curvelops.plot import curveshow 59 | >>> d = np.random.randn(101, 101) 60 | >>> C = FDCT2D(d.shape, nbscales=2, nbangles_coarse=8) 61 | >>> y = C.struct(C @ d) 62 | >>> y_norm = apply_along_wedges(y, lambda w, *_: w / energy(w)) 63 | >>> curveshow( 64 | >>> y_norm, 65 | >>> basesize=2, 66 | >>> kwargs_imshow=dict(aspect="auto", vmin=-1, vmax=1, cmap="RdBu") 67 | >>> ) 68 | 69 | Returns 70 | ------- 71 | List[:obj:`Figure `] 72 | One figure per scale. 73 | """ 74 | 75 | def fft(x): 76 | return np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(x), norm="ortho")) 77 | 78 | _kwargs_imshow_default = {} 79 | if k_space: 80 | _kwargs_imshow_default["vmax"] = np.abs(fft(c_struct[0][0])).max() 81 | _kwargs_imshow_default["vmin"] = 0.0 82 | _kwargs_imshow_default["cmap"] = "turbo" 83 | else: 84 | _kwargs_imshow_default["vmax"] = np.abs(c_struct[0][0]).max() 85 | _kwargs_imshow_default["vmin"] = -_kwargs_imshow_default["vmax"] 86 | _kwargs_imshow_default["cmap"] = "gray" 87 | if kwargs_imshow is None: 88 | kwargs_imshow = _kwargs_imshow_default 89 | else: 90 | kwargs_imshow = {**_kwargs_imshow_default, **kwargs_imshow} 91 | 92 | figsize_aspect = c_struct[0][0].shape[0] / c_struct[0][0].shape[1] 93 | figs_axes = [] 94 | for iscale, c_scale in enumerate(c_struct): 95 | nangles = len(c_scale) 96 | rows = floor(np.sqrt(nangles)) 97 | cols = ceil(nangles / rows) 98 | fig, axes = plt.subplots( 99 | rows, 100 | cols, 101 | figsize=(basesize * cols, figsize_aspect * basesize * rows), 102 | ) 103 | fig.suptitle(f"Scale {iscale} ({nangles} wedge{'s' if nangles > 1 else ''})") 104 | figs_axes.append((fig, axes)) 105 | axes = np.atleast_1d(axes).ravel() 106 | 107 | for iwedge, (c_wedge, ax) in enumerate(zip(c_scale, axes)): 108 | if k_space: 109 | ax.imshow(np.abs(fft(c_wedge)), **kwargs_imshow) 110 | else: 111 | if real: 112 | ax.imshow(c_wedge.real, **kwargs_imshow) 113 | else: 114 | ax.imshow(c_wedge.imag, **kwargs_imshow) 115 | if nangles > 1: 116 | ax.set(title=f"Wedge {iwedge}") 117 | if not showaxis: 118 | ax.axis("off") 119 | fig.tight_layout() 120 | return figs_axes 121 | 122 | 123 | def overlay_disks( 124 | c_struct: FDCTStructLike, 125 | axes: NDArray, 126 | linewidth: float = 0.5, 127 | linecolor: str = "r", 128 | map_cmap: bool = True, 129 | cmap: Union[str, Colormap] = "gray_r", 130 | alpha: float = 1.0, 131 | pclip: float = 1.0, 132 | map_alpha: bool = False, 133 | min_alpha: float = 0.05, 134 | normalize: str = "all", 135 | annotate: bool = False, 136 | ): 137 | """Overlay curvelet disks over a 2D grid of axes. 138 | 139 | Its intended usage is to display the strength of curvelet coefficients 140 | of a certain image with a disk display. Given an ``axes`` 2D array, 141 | each curvelet wedge will be split into ``rows, cols = axes.shape`` 142 | sub-wedges. The energy of each of these sub-wedges will be mapped 143 | to a colormap color and/or transparency. 144 | 145 | See Also 146 | -------- 147 | :obj:`energy_split `: Splits a wedge into ``(rows, cols)`` wedges and computes the energy of each of these subdivisions. 148 | 149 | :obj:`create_inset_axes_grid`: Create a grid of insets. 150 | 151 | :obj:`create_axes_grid`: Creates a grid of axes. 152 | 153 | :obj:`curveshow`: Display curvelet coefficients in each wedge as images. 154 | 155 | Parameters 156 | ---------- 157 | c_struct : :obj:`FDCTStructLike `: 158 | Curvelet coefficients of underlying image. 159 | axes : :obj:`NDArray ` 160 | 2D grid of axes for which disks will be computed. 161 | linewidth : :obj:`float`, optional 162 | Width of line separating scales, by default 0.5. 163 | Will be scaled by ``0.1 / nscales`` internally. 164 | Set to zero to disable. 165 | linecolor : :obj:`str`, optional 166 | Color of line separating scales, by default "r". 167 | map_cmap : :obj:`bool`, optional 168 | When enabled, energy will be mapped to the colormap, by default True. 169 | cmap : Union[:obj:`str`, :obj:`Colormap `], optional 170 | Colormap, by default ``"gray_r"``. 171 | alpha : :obj:`float`, optional 172 | When using ``map_cmap``, sets a transparecy for all wedges. 173 | Has no effect when ``map_alpha`` is enabled. By default 1.0. 174 | pclip : :obj:`float`, optional 175 | Clips the maximum amplitude by this percentage. By default 1.0. 176 | Should be between 0.0 and 1.0. 177 | map_alpha : :obj:`bool`, optional 178 | When enabled, energy will be mapped to the transparency, by default False. 179 | min_alpha : :obj:`float`, optional 180 | When using ``map_alpha``, sets a minimum transparency value. 181 | Has no effect when ``map_alpha`` is disabled. By default 0.05. 182 | normalize : :obj:`str`, optional 183 | Normalize wedges by: 184 | 185 | * ``"all"`` (default) 186 | Colormap/alpha value of 1.0 will correspond to the maximum 187 | energy found across all wedges 188 | 189 | * ``"scale"`` 190 | Colormap/alpha value of 1.0 will correspond to the maximum 191 | energy found across all wedges in the same scale. 192 | annotate : :obj:`bool`, optional 193 | When true, will display in the middle of the wedge a 194 | pair of numbers ``iscale, iwedge``, the index of that scale 195 | and that wedge, both starting from zero. This option is useful to 196 | understand which directions each wedge corresponds to. 197 | By default False. 198 | 199 | Examples 200 | -------- 201 | >>> import matplotlib.pyplot as plt 202 | >>> import numpy as np 203 | >>> from curvelops import FDCT2D 204 | >>> from curvelops.utils import apply_along_wedges 205 | >>> from curvelops.plot import create_axes_grid, overlay_disks 206 | >>> x = np.random.randn(50, 100) 207 | >>> C = FDCT2D(x.shape, nbscales=4, nbangles_coarse=8) 208 | >>> y = C.struct(C @ x) 209 | >>> y_ones = apply_along_wedges(y, lambda w, *_: np.ones_like(w)) 210 | >>> fig, axes = create_axes_grid( 211 | >>> 1, 212 | >>> 1, 213 | >>> kwargs_subplots=dict(projection="polar"), 214 | >>> kwargs_figure=dict(figsize=(8, 8)), 215 | >>> ) 216 | >>> overlay_disks(y_ones, axes, annotate=True, cmap="gray") 217 | 218 | >>> import matplotlib as mpl 219 | >>> import matplotlib.pyplot as plt 220 | >>> import numpy as np 221 | >>> from mpl_toolkits.axes_grid1 import make_axes_locatable 222 | >>> from curvelops import FDCT2D 223 | >>> from curvelops.plot import create_inset_axes_grid, overlay_disks 224 | >>> from curvelops.utils import apply_along_wedges 225 | >>> plt.rcParams.update({"image.interpolation": "blackman"}) 226 | >>> # Construct signal 227 | >>> xlim = [-1.0, 1.0] 228 | >>> ylim = [-0.5, 0.5] 229 | >>> x = np.linspace(*xlim, 201) 230 | >>> z = np.linspace(*ylim, 101) 231 | >>> xm, zm = np.meshgrid(x, z, indexing="ij") 232 | >>> freq = 5 233 | >>> d = np.cos(2 * np.pi * freq * (xm + np.cos(xm) * zm) ** 3) 234 | >>> # Compute curvelet coefficients 235 | >>> C = FDCT2D(d.shape, nbangles_coarse=8, allcurvelets=False) 236 | >>> d_c = C.struct(C @ d) 237 | >>> # Plot original signal 238 | >>> fig, ax = plt.subplots(figsize=(8, 4 )) 239 | >>> ax.imshow(d.T, extent=[*xlim, *(ylim[::-1])], cmap="RdYlBu", vmin=-1, vmax=1) 240 | >>> ax.axis("off") 241 | >>> # Overlay disks 242 | >>> rows, cols = 3, 6 243 | >>> axesin = create_inset_axes_grid( 244 | >>> ax, rows, cols, width=0.75, kwargs_inset_axes=dict(projection="polar") 245 | >>> ) 246 | >>> pclip = 0.2 247 | >>> cmap = "gray_r" 248 | >>> overlay_disks(d_c, axesin, linewidth=0.0, pclip=pclip, cmap=cmap) 249 | >>> # Display disk colorbar 250 | >>> divider = make_axes_locatable(ax) 251 | >>> cax = divider.append_axes("right", size="5%", pad=0.1) 252 | >>> mpl.colorbar.ColorbarBase( 253 | >>> cax, cmap=cmap, norm=mpl.colors.Normalize(vmin=0, vmax=pclip) 254 | >>> ) 255 | """ 256 | rows, cols = axes.shape 257 | e_split = apply_along_wedges(c_struct, lambda w, *_: energy_split(w, rows, cols)) 258 | max_e = max(a.max() for a in itertools.chain.from_iterable(e_split)) 259 | 260 | cmapper = cm.ScalarMappable( 261 | norm=mpl.colors.Normalize(0, pclip, clip=True), cmap=cmap 262 | ) 263 | 264 | nscales = len(c_struct) 265 | linewidth *= 0.1 / nscales 266 | 267 | for iscale in range(nscales): 268 | nangles = len(c_struct[iscale]) 269 | angles_per_wedge = 2 * np.pi / nangles 270 | 271 | if normalize == "scale": 272 | max_e = max(a.max() for a in itertools.chain.from_iterable(e_split[iscale])) 273 | 274 | # To start starting counterclockwise from the top middle, 275 | # we need to offset the wedge index by the following amount 276 | iwedge_offset = nangles - nangles // 8 277 | for iwedge in range(nangles): 278 | for irow in range(rows): 279 | for icol in range(cols): 280 | e = e_split[iscale][iwedge][irow, icol] 281 | if map_alpha: 282 | alpha = np.clip( 283 | min_alpha + (1 - min_alpha) * e / max_e, 284 | min_alpha, 285 | 1, 286 | ) 287 | if map_cmap: 288 | color = cmapper.to_rgba(np.clip(e / max_e, 0, 1)) 289 | else: 290 | color = cmapper.to_rgba(1) 291 | 292 | # Place the starting wedges in the correct place 293 | iwedge_shift = (nangles // 2 + iwedge + iwedge_offset) % nangles 294 | 295 | # Wedge coordinates in polar plot 296 | wedge_x = (iwedge_shift + 0.5) * angles_per_wedge 297 | wedge_width = angles_per_wedge 298 | wedge_height = 1 / (nscales - 1) 299 | wedge_bottom = iscale * wedge_height 300 | axes[irow][icol].bar( 301 | x=wedge_x, 302 | height=wedge_height, 303 | width=wedge_width, 304 | bottom=wedge_bottom, 305 | color=color, 306 | alpha=alpha, 307 | ) 308 | if nangles > 1: 309 | axes[irow][icol].bar( 310 | x=wedge_x - wedge_width / 2, 311 | height=wedge_height, 312 | width=linewidth, 313 | bottom=wedge_bottom, 314 | color=linecolor, 315 | ) 316 | if annotate: 317 | axes[irow][icol].text( 318 | wedge_x, 319 | wedge_bottom 320 | + (0 if wedge_bottom == 0 else wedge_height / 2), 321 | f"{iscale}, {iwedge}", 322 | backgroundcolor="w", 323 | color="k", 324 | horizontalalignment="center", 325 | verticalalignment="center", 326 | fontsize=6, 327 | ) 328 | 329 | # Plot line separating scales 330 | for irow in range(rows): 331 | for icol in range(cols): 332 | axes[irow][icol].axis("off") 333 | for iscale in range(nscales): 334 | if linewidth > 0.0: 335 | axes[irow][icol].bar( 336 | x=0, 337 | height=linewidth, 338 | width=2 * np.pi, 339 | bottom=(iscale + 1 - linewidth / 2) / (nscales - 1), 340 | color=linecolor, 341 | ) 342 | -------------------------------------------------------------------------------- /curvelops/plot/_generic.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "create_colorbar", 3 | "create_axes_grid", 4 | "create_inset_axes_grid", 5 | "overlay_arrows", 6 | ] 7 | from typing import Optional, Tuple 8 | 9 | import matplotlib.pyplot as plt 10 | import numpy as np 11 | from matplotlib.axes import Axes 12 | from matplotlib.colorbar import Colorbar 13 | from matplotlib.figure import Figure 14 | from matplotlib.image import AxesImage 15 | from mpl_toolkits.axes_grid1 import make_axes_locatable 16 | from numpy.typing import NDArray 17 | 18 | 19 | def _create_range(start, end, n): 20 | return start + (end - start) * (0.5 + np.arange(n)) / n 21 | 22 | 23 | def create_colorbar( 24 | im: AxesImage, 25 | ax: Axes, 26 | size: float = 0.05, 27 | pad: float = 0.1, 28 | orientation: str = "vertical", 29 | ) -> Tuple[Axes, Colorbar]: 30 | r"""Create a colorbar. 31 | 32 | Divides axis and attaches a colorbar to it. 33 | 34 | Parameters 35 | ---------- 36 | im : :obj:`AxesImage ` 37 | Image from which the colorbar will be created. 38 | Commonly the output of :obj:`matplotlib.pyplot.imshow`. 39 | ax : :obj:`Axes ` 40 | Axis which to split. 41 | size : :obj:`float`, optional 42 | Size of split, by default 0.05. Effectively sets the size of the colorbar. 43 | pad : :obj:`float`, optional` 44 | Padding between colorbar axis and input axis, by default 0.1. 45 | orientation : :obj:`str`, optional 46 | Orientation of the colorbar, by default "vertical". 47 | 48 | Returns 49 | ------- 50 | Tuple[:obj:`Axes `, :obj:`Colorbar `] 51 | **cax** : Colorbar axis. 52 | 53 | **cb** : Colorbar. 54 | 55 | Examples 56 | -------- 57 | >>> import matplotlib.pyplot as plt 58 | >>> from matplotlib.ticker import MultipleLocator 59 | >>> from curvelops.plot import create_colorbar 60 | >>> fig, ax = plt.subplots() 61 | >>> im = ax.imshow([[0]], vmin=-1, vmax=1, cmap="gray") 62 | >>> cax, cb = create_colorbar(im, ax) 63 | >>> cax.yaxis.set_major_locator(MultipleLocator(0.1)) 64 | >>> print(cb.vmin) 65 | -1.0 66 | """ 67 | divider = make_axes_locatable(ax) 68 | cax = divider.append_axes("right", size=f"{size:%}", pad=pad) 69 | cb = ax.get_figure().colorbar(im, cax=cax, orientation=orientation) 70 | return cax, cb 71 | 72 | 73 | def create_axes_grid( 74 | rows: int, 75 | cols: int, 76 | kwargs_figure: Optional[dict] = None, 77 | kwargs_gridspec: Optional[dict] = None, 78 | kwargs_subplots: Optional[dict] = None, 79 | ) -> Tuple[Figure, NDArray]: 80 | r"""Creates a grid of axes. 81 | 82 | Parameters 83 | ---------- 84 | rows : :obj:`int` 85 | Number of rows. 86 | cols : :obj:`int` 87 | Number of columns. 88 | kwargs_figure : ``Optional[dict]``, optional 89 | Arguments to be passed to :obj:`matplotlib.pyplot.figure`. 90 | kwargs_gridspec : ``Optional[dict]``, optional 91 | Arguments to be passed to :obj:`matplotlib.gridspec.GridSpec`. 92 | kwargs_subplots : ``Optional[dict]``, optional 93 | Arguments to be passed to :obj:`matplotlib.figure.Figure.add_subplot`. 94 | 95 | 96 | Returns 97 | ------- 98 | Tuple[:obj:`Figure `, :obj:`NDArray `] 99 | **fig** : Figure. 100 | 101 | **axs** : Array of :obj:`Axes ` shaped ``(rows, cols)``. 102 | 103 | Examples 104 | -------- 105 | >>> from curvelops.plot import create_axes_grid 106 | >>> rows, cols = 2, 3 107 | >>> fig, axs = create_axes_grid( 108 | >>> rows, 109 | >>> cols, 110 | >>> kwargs_figure=dict(figsize=(8, 8)), 111 | >>> kwargs_gridspec=dict(wspace=0.3, hspace=0.3), 112 | >>> ) 113 | >>> for irow in range(rows): 114 | >>> for icol in range(cols): 115 | >>> axs[irow][icol].plot(np.cos((2 + irow + icol**2) * np.linspace(0, 1))) 116 | >>> axs[irow][icol].set(title=f"Row, Col: ({irow}, {icol})") 117 | """ 118 | if kwargs_figure is None: 119 | kwargs_figure = {} 120 | if kwargs_gridspec is None: 121 | kwargs_gridspec = {} 122 | if kwargs_subplots is None: 123 | kwargs_subplots = {} 124 | fig = plt.figure(**kwargs_figure) 125 | grid = fig.add_gridspec(rows, cols, **kwargs_gridspec) 126 | axs = np.empty((rows, cols), dtype=Axes) 127 | for irow in range(rows): 128 | for icol in range(cols): 129 | axs[irow, icol] = fig.add_subplot(grid[irow, icol], **kwargs_subplots) 130 | return fig, axs 131 | 132 | 133 | def create_inset_axes_grid( 134 | ax: Axes, 135 | rows: int, 136 | cols: int, 137 | height: float = 0.5, 138 | width: float = 0.5, 139 | kwargs_inset_axes: Optional[dict] = None, 140 | ) -> NDArray: 141 | r"""Create a grid of insets. 142 | 143 | The input axis will be overlaid with a grid of insets. 144 | Numbering of the axes is top to bottom (rows) and 145 | left to right (cols). 146 | 147 | Parameters 148 | ---------- 149 | ax : :obj:`Axes ` 150 | Input axis. 151 | rows : :obj:`int` 152 | Number of rows. 153 | cols : :obj:`int` 154 | Number of columns. 155 | width : :obj:`float`, optional 156 | Width of each axis, as a percentage of ``cols``, by default 0.5. 157 | height : :obj:`float`, optional 158 | Height of each axis, as a percentage of ``rows``, by default 0.5. 159 | kwargs_inset_axes : ``Optional[dict]``, optional 160 | Arguments to be passed to :obj:`matplotlib.axes.Axes.inset_axes`. 161 | 162 | Returns 163 | ------- 164 | :obj:`NDArray ` 165 | Array of :obj:`Axes ` shaped ``(rows, cols)``. 166 | 167 | Examples 168 | -------- 169 | >>> import matplotlib.pyplot as plt 170 | >>> import numpy as np 171 | >>> from curvelops.plot import create_inset_axes_grid 172 | >>> fig, ax = plt.subplots(figsize=(6, 6)) 173 | >>> ax.imshow([[0]], extent=[-2, 2, 2, -2], vmin=-1, vmax=1, cmap="gray") 174 | >>> rows, cols = 2, 3 175 | >>> inset_axes = create_inset_axes_grid( 176 | >>> ax, 177 | >>> rows, 178 | >>> cols, 179 | >>> width=0.5, 180 | >>> height=0.5, 181 | >>> kwargs_inset_axes=dict(projection="polar"), 182 | >>> ) 183 | >>> nscales = 4 184 | >>> lw = 0.1 185 | >>> for irow in range(rows): 186 | >>> for icol in range(cols): 187 | >>> for iscale in range(1, nscales): 188 | >>> inset_axes[irow][icol].bar( 189 | >>> x=0, 190 | >>> height=lw, 191 | >>> width=2 * np.pi, 192 | >>> bottom=((iscale + 1) - 0.5 * lw) / (nscales - 1), 193 | >>> color="r", 194 | >>> ) 195 | >>> inset_axes[irow][icol].set(title=f"Row, Col: ({irow}, {icol})") 196 | >>> inset_axes[irow][icol].axis("off") 197 | """ 198 | if kwargs_inset_axes is None: 199 | kwargs_inset_axes = {} 200 | 201 | axes = np.empty((rows, cols), dtype=object) 202 | 203 | xmin, xmax = ax.get_xlim() 204 | ymin, ymax = ax.get_ylim() 205 | xmin, xmax = min(xmin, xmax), max(xmin, xmax) 206 | ymin, ymax = min(ymin, ymax), max(ymin, ymax) 207 | 208 | width *= (xmax - xmin) / cols 209 | height *= (ymax - ymin) / rows 210 | 211 | for irow, rowpos in enumerate(_create_range(ymin, ymax, rows)): 212 | for icol, colpos in enumerate(_create_range(xmin, xmax, cols)): 213 | axes[irow, icol] = ax.inset_axes( 214 | [colpos - 0.5 * width, rowpos - 0.5 * height, width, height], 215 | transform=ax.transData, 216 | **kwargs_inset_axes, 217 | ) 218 | return axes 219 | 220 | 221 | def overlay_arrows( 222 | vectors: NDArray, ax: Axes, arrowprops: Optional[dict] = None 223 | ) -> None: 224 | r"""Overlay arrows on an axis. 225 | 226 | Parameters 227 | ---------- 228 | vectors : :obj:`NDArray ` 229 | Array shaped ``(rows, cols, 2)``, corresponding to a 2D vector field. 230 | ax : :obj:`Axes ` 231 | Axis on which to overlay the arrows. 232 | arrowprops : ``Optional[dict]``, optional 233 | Arrow properties, to be passed to :obj:`matplotlib.pyplot.annotate`. 234 | By default will be set to ``dict(facecolor="black", shrink=0.05)``. 235 | 236 | Examples 237 | -------- 238 | >>> import matplotlib.pyplot as plt 239 | >>> import numpy as np 240 | >>> from curvelops.plot import overlay_arrows 241 | >>> fig, ax = plt.subplots(figsize=(8, 10)) 242 | >>> ax.imshow([[0]], vmin=-1, vmax=1, extent=[0, 1, 1, 0], cmap="gray") 243 | >>> rows, cols = 3, 4 244 | >>> kvecs = np.array( 245 | >>> [ 246 | >>> [(1 + x, x * y) for x in (0.5 + np.arange(cols)) / cols] 247 | >>> for y in (0.5 + np.arange(rows)) / rows 248 | >>> ] 249 | >>> ) 250 | >>> overlay_arrows( 251 | >>> 0.05 * kvecs, 252 | >>> ax, 253 | >>> arrowprops=dict( 254 | >>> facecolor="r", 255 | >>> shrink=0.05, 256 | >>> width=10 / cols, 257 | >>> headwidth=10, 258 | >>> headlength=10, 259 | >>> ), 260 | >>> ) 261 | """ 262 | rows, cols, _ = vectors.shape 263 | 264 | xmin, xmax = ax.get_xlim() 265 | ymin, ymax = ax.get_ylim() 266 | xmin, xmax = min(xmin, xmax), max(xmin, xmax) 267 | ymin, ymax = min(ymin, ymax), max(ymin, ymax) 268 | 269 | if arrowprops is None: 270 | arrowprops = dict(facecolor="black", shrink=0.05) 271 | 272 | for irow, rowpos in enumerate(_create_range(ymin, ymax, rows)): 273 | for icol, colpos in enumerate(_create_range(xmin, xmax, cols)): 274 | ax.annotate( 275 | "", 276 | xy=( 277 | colpos + vectors[irow, icol, 0], 278 | rowpos + vectors[irow, icol, 1], 279 | ), 280 | xytext=(colpos, rowpos), 281 | xycoords="data", 282 | arrowprops=arrowprops, 283 | annotation_clip=False, 284 | ) 285 | -------------------------------------------------------------------------------- /curvelops/typing/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ``curvelops.typing`` 3 | ==================== 4 | 5 | Typing submodule. 6 | """ 7 | 8 | from . import _typing 9 | 10 | __all__ = _typing.__all__.copy() 11 | 12 | 13 | from ._typing import * 14 | -------------------------------------------------------------------------------- /curvelops/typing/_typing.py: -------------------------------------------------------------------------------- 1 | __all__ = ["FDCTStructLike", "RecursiveListNDArray"] 2 | from typing import List, Union 3 | 4 | from numpy.typing import NDArray 5 | 6 | FDCTStructLike = List[List[NDArray]] 7 | RecursiveListNDArray = Union[List[NDArray], List["RecursiveListNDArray"]] 8 | -------------------------------------------------------------------------------- /curvelops/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ``curvelops.utils`` 3 | =================== 4 | 5 | Utility functions for processing curvelets. 6 | """ 7 | 8 | from . import _utils 9 | 10 | __all__ = _utils.__all__.copy() 11 | 12 | 13 | from ._utils import * 14 | -------------------------------------------------------------------------------- /curvelops/utils/_utils.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "array_split_nd", 3 | "split_nd", 4 | "apply_along_wedges", 5 | "energy", 6 | "energy_split", 7 | "ndargmax", 8 | ] 9 | from typing import Callable, List, TypeVar 10 | 11 | import numpy as np 12 | from numpy.typing import NDArray 13 | 14 | from ..typing._typing import FDCTStructLike, RecursiveListNDArray 15 | 16 | 17 | def array_split_nd(ary: NDArray, *args: int) -> RecursiveListNDArray: 18 | r"""Split an array into multiple sub-arrays recursively, possibly unevenly. 19 | 20 | See Also 21 | -------- 22 | :obj:`numpy.array_split` : Split an array into multiple sub-arrays. 23 | 24 | :obj:`split_nd`: Evenly split an array into multiple sub-arrays recursively. 25 | 26 | Parameters 27 | ---------- 28 | ary : :obj:`NDArray ` 29 | Input array. 30 | 31 | args : :obj:`int`, optional 32 | Number of splits for each axis of `ary`. 33 | Axis 0 will be split into `args[0]` subarrays, axis 1 will be 34 | into `args[1]` subarrays, etc. An axis of length 35 | `l = ary.shape[axis]` that should be split into `n = args[axis]` 36 | sections, will return `l % n` sub-arrays of size `l//n + 1` 37 | and the rest of size `l//n`. 38 | 39 | Returns 40 | ------- 41 | :obj:`RecursiveListNDArray ` 42 | Recursive lists of lists of :obj:`NDArray `. 43 | The number of recursions is equivalent to the number arguments in args. 44 | 45 | Examples 46 | -------- 47 | >>> from curvelops.utils import array_split_nd 48 | >>> ary = np.outer(1 + np.arange(2), 2 + np.arange(3)) 49 | array([[2, 3, 4], 50 | [4, 6, 8]]) 51 | >>> array_split_nd(ary, 2, 3) 52 | [[array([[2]]), array([[3]]), array([[4]])], 53 | [array([[4]]), array([[6]]), array([[8]])]] 54 | 55 | >>> from curvelops.utils import array_split_nd 56 | >>> ary = np.outer(np.arange(3), np.arange(5)) 57 | >>> array_split_nd(ary, 2, 3) 58 | [[array([[0, 0], 59 | [0, 1]]), 60 | array([[0, 0], 61 | [2, 3]]), 62 | array([[0], 63 | [4]])], 64 | [array([[0, 2]]), array([[4, 6]]), array([[8]])]] 65 | """ 66 | axis = ary.ndim - len(args) 67 | split = np.array_split(ary, args[0], axis=axis) 68 | if len(args) == 1: 69 | return split 70 | return [array_split_nd(s, *args[1:]) for s in split] 71 | 72 | 73 | def split_nd(ary: NDArray, *args: int) -> RecursiveListNDArray: 74 | r"""Evenly split an array into multiple sub-arrays recursively. 75 | 76 | See Also 77 | -------- 78 | :obj:`numpy.split` : Split an array into multiple sub-arrays. 79 | 80 | :obj:`array_split_nd`: Split an array into multiple sub-arrays recursively, possibly unevenly. 81 | 82 | 83 | Parameters 84 | ---------- 85 | ary : :obj:`NDArray ` 86 | Input array. 87 | 88 | args : :obj:`int`, optional 89 | Number of splits for each axis of `ary`. 90 | Axis 0 will be split into `args[0]` subarrays, axis 1 will be 91 | into `args[1]` subarrays, etc. If the split cannot be made even 92 | for all dimensions, raises an error. 93 | 94 | Returns 95 | ------- 96 | :obj:`RecursiveListNDArray ` 97 | Recursive lists of lists of :obj:`NDArray `. 98 | The number of recursions is equivalent to the number arguments in args. 99 | 100 | Examples 101 | -------- 102 | >>> from curvelops.utils import split_nd 103 | >>> ary = np.outer(1 + np.arange(2), 2 + np.arange(3)) 104 | array([[2, 3, 4], 105 | [4, 6, 8]]) 106 | >>> split_nd(ary, 2, 3) 107 | [[array([[2]]), array([[3]]), array([[4]])], 108 | [array([[4]]), array([[6]]), array([[8]])]] 109 | 110 | >>> from curvelops.utils import split_nd 111 | >>> ary = np.outer(np.arange(3), np.arange(5)) 112 | >>> split_nd(ary, 2, 3) 113 | ValueError: array split does not result in an equal division 114 | """ 115 | axis = ary.ndim - len(args) 116 | split = np.split(ary, args[0], axis=axis) 117 | if len(args) == 1: 118 | return split 119 | return [split_nd(s, *args[1:]) for s in split] 120 | 121 | 122 | T = TypeVar("T") 123 | 124 | 125 | def apply_along_wedges( 126 | c_struct: FDCTStructLike, fun: Callable[[NDArray, int, int, int, int], T] 127 | ) -> List[List[T]]: 128 | """Applies a function to each individual wedge. 129 | 130 | Parameters 131 | ---------- 132 | c_struct : :obj:`FDCTStructLike ` 133 | Input curvelet coefficients in struct format. 134 | fun : Callable[[:obj:`NDArray `, :obj:`int`, :obj:`int`, :obj:`int`, :obj:`int`], T] 135 | Function to apply to each individual wedge. The function's arguments 136 | are respectively: `wedge`, `wedge index in scale`, `scale index`, `number of 137 | wedges in scale`, `number of scales`. 138 | 139 | Returns 140 | ------- 141 | List[List[T]] 142 | Struct containing the result of applying `fun` to each wedge. 143 | 144 | Examples 145 | -------- 146 | >>> import numpy as np 147 | >>> from curvelops import FDCT2D 148 | >>> from curvelops.utils import apply_along_wedges 149 | >>> x = np.zeros((32, 32)) 150 | >>> C = FDCT2D(x.shape, nbscales=3, nbangles_coarse=8, allcurvelets=False) 151 | >>> y = C.struct(C @ x) 152 | >>> apply_along_wedges(y, lambda w, *_: w.shape) 153 | [[(11, 11)], 154 | [(23, 11), 155 | (23, 11), 156 | (11, 23), 157 | (11, 23), 158 | (23, 11), 159 | (23, 11), 160 | (11, 23), 161 | (11, 23)], 162 | [(32, 32)]] 163 | """ 164 | mapped_struct: List[List[T]] = [[] for _ in c_struct] 165 | for iscale, c_angles in enumerate(c_struct): 166 | mapped_struct[iscale] = [] 167 | for iwedge, c_wedge in enumerate(c_angles): 168 | out = fun(c_wedge, iwedge, iscale, len(c_angles), len(c_struct)) 169 | mapped_struct[iscale].append(out) 170 | return mapped_struct 171 | 172 | 173 | def energy(ary: NDArray) -> float: 174 | r"""Computes the energy of an n-dimensional wedge. 175 | 176 | The energy of a vector (flattened n-dimensional array) 177 | :math:`(a_0,\ldots,a_{N-1})` is defined as 178 | 179 | .. math:: 180 | 181 | \sqrt{\frac{1}{N}\sum\limits_{i=0}^{N-1} |a_i|^2}. 182 | 183 | Parameters 184 | ---------- 185 | ary : :obj:`NDArray ` 186 | Input wedge. 187 | 188 | Returns 189 | ------- 190 | :obj:`float` 191 | Energy. 192 | """ 193 | return np.sqrt((ary.real**2 + ary.imag**2).sum() / ary.size) 194 | 195 | 196 | def energy_split(ary: NDArray, rows: int, cols: int) -> NDArray: 197 | """Splits a wedge into ``(rows, cols)`` wedges and computes the energy 198 | of each of these subdivisions. 199 | 200 | See Also 201 | -------- 202 | 203 | :obj:`energy` : Computes the energy of a wedge. 204 | 205 | Parameters 206 | ---------- 207 | ary : :obj:`NDArray ` 208 | Input wedge. 209 | rows : :obj:`int` 210 | Split axis 0 into `rows` subdivisions. 211 | cols : :obj:`int` 212 | Split axis 1 into `cols` subdivisions. 213 | 214 | Returns 215 | ------- 216 | :obj:`NDArray ` 217 | Matrix of shape ``(rows, cols)`` containing the energy of each 218 | subdivision of the input wedge. 219 | """ 220 | norm_local = np.empty((rows, cols), dtype=float) 221 | split = array_split_nd(ary, rows, cols) 222 | for irow in range(rows): 223 | for icol in range(cols): 224 | norm_local[irow, icol] = energy(split[irow][icol]) 225 | return norm_local 226 | 227 | 228 | def ndargmax(ary: NDArray) -> tuple: 229 | """N-dimensional argmax of array. 230 | 231 | Parameters 232 | ---------- 233 | ary : :obj:`NDArray ` 234 | Input array 235 | 236 | Examples 237 | -------- 238 | >>> import numpy as np 239 | >>> from curvelops.utils import ndargmax 240 | >>> x = np.zeros((10, 10, 10)) 241 | >>> x[1, 1, 1] = 1.0 242 | >>> ndargmax(x) 243 | (1, 1, 1) 244 | 245 | Returns 246 | ------- 247 | tuple 248 | N-dimensional index of the maximum of ``ary``. 249 | """ 250 | return np.unravel_index(ary.argmax(), ary.shape) 251 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyLops/curvelops/d6dc5fde8bcf399e57f81c5beb38449eb8863e45/docs/.nojekyll -------------------------------------------------------------------------------- /docssrc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # Disable numba 5 | # export NUMBA_DISABLE_JIT=1 6 | 7 | # You can set these variables from the command line. 8 | SPHINXOPTS = 9 | SPHINXBUILD = sphinx-build 10 | SPHINXPROJ = curvelops 11 | SOURCEDIR = source 12 | BUILDDIR = build 13 | 14 | # Put it first so that "make" without argument is like "make help". 15 | help: 16 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 17 | 18 | .PHONY: help Makefile 19 | 20 | # Catch-all target: route all unknown targets to Sphinx using the new 21 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 22 | %: Makefile 23 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 24 | 25 | # Make for github pages 26 | github: 27 | @make html 28 | @cp -a build/html/. ../docs 29 | -------------------------------------------------------------------------------- /docssrc/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | 19 | from sphinx_gallery.sorting import ExampleTitleSortKey 20 | 21 | from curvelops import __version__ as version 22 | 23 | release = version 24 | 25 | # -- Project information ----------------------------------------------------- 26 | 27 | project = "curvelops" 28 | copyright = "2020-2023, Carlos Alberto da Costa Filho" 29 | author = "Carlos Alberto da Costa Filho" 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | "sphinx.ext.autodoc", 43 | "sphinx.ext.doctest", 44 | "sphinx.ext.mathjax", 45 | "sphinx.ext.ifconfig", 46 | "sphinx.ext.viewcode", 47 | "sphinx.ext.intersphinx", 48 | "sphinx_gallery.gen_gallery", 49 | "sphinx_copybutton", 50 | ] 51 | 52 | # intersphinx configuration 53 | intersphinx_mapping = { 54 | "python": ("https://docs.python.org/3/", None), 55 | "numpy": ("https://docs.scipy.org/doc/numpy/", None), 56 | "matplotlib": ("https://matplotlib.org/", None), 57 | } 58 | 59 | # Add any paths that contain templates here, relative to this directory. 60 | templates_path = ["_templates"] 61 | 62 | # The suffix(es) of source filenames. 63 | # You can specify multiple suffix as a list of string: 64 | # 65 | # source_suffix = ['.rst', '.md'] 66 | source_suffix = ".rst" 67 | 68 | # The master toctree document. 69 | master_doc = "index" 70 | 71 | # The language for content autogenerated by Sphinx. Refer to documentation 72 | # for a list of supported languages. 73 | # 74 | # This is also used if you do content translation via gettext catalogs. 75 | # Usually you set "language" from the command line for these cases. 76 | language = "en" 77 | 78 | # List of patterns, relative to source directory, that match files and 79 | # directories to ignore when looking for source files. 80 | # This pattern also affects html_static_path and html_extra_path. 81 | exclude_patterns = [] 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = None 85 | 86 | 87 | # -- Options for HTML output ------------------------------------------------- 88 | 89 | # The theme to use for HTML and HTML Help pages. See the documentation for 90 | # a list of builtin themes. 91 | # 92 | html_theme = "pydata_sphinx_theme" 93 | 94 | # Theme options are theme-specific and customize the look and feel of a theme 95 | # further. For a list of options available for each theme, see the 96 | # documentation. 97 | # 98 | # html_theme_options = {} 99 | 100 | # Add any paths that contain custom static files (such as style sheets) here, 101 | # relative to this directory. They are copied after the builtin static files, 102 | # so a file named "default.css" will overwrite the builtin "default.css". 103 | html_static_path = ["_static"] 104 | 105 | # Custom sidebar templates, must be a dictionary that maps document names 106 | # to template names. 107 | # 108 | # The default sidebars (for documents that don't match any pattern) are 109 | # defined by theme itself. Builtin themes are using these templates by 110 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 111 | # 'searchbox.html']``. 112 | # 113 | # html_sidebars = {} 114 | 115 | 116 | # -- Options for HTMLHelp output --------------------------------------------- 117 | 118 | # Output file base name for HTML help builder. 119 | htmlhelp_basename = "curvelopsdoc" 120 | 121 | 122 | # -- Options for LaTeX output ------------------------------------------------ 123 | 124 | latex_elements = { 125 | # The paper size ('letterpaper' or 'a4paper'). 126 | # 127 | # 'papersize': 'letterpaper', 128 | # The font size ('10pt', '11pt' or '12pt'). 129 | # 130 | # 'pointsize': '10pt', 131 | # Additional stuff for the LaTeX preamble. 132 | # 133 | # 'preamble': '', 134 | # Latex figure (float) alignment 135 | # 136 | # 'figure_align': 'htbp', 137 | } 138 | 139 | # Grouping the document tree into LaTeX files. List of tuples 140 | # (source start file, target name, title, 141 | # author, documentclass [howto, manual, or own class]). 142 | latex_documents = [ 143 | ( 144 | master_doc, 145 | "curvelops.tex", 146 | "curvelops Documentation", 147 | "Carlos Alberto da Costa Filho", 148 | "manual", 149 | ), 150 | ] 151 | 152 | 153 | # -- Options for manual page output ------------------------------------------ 154 | 155 | # One entry per manual page. List of tuples 156 | # (source start file, name, description, authors, manual section). 157 | man_pages = [(master_doc, "curvelops", "curvelops Documentation", [author], 1)] 158 | 159 | 160 | # -- Options for Texinfo output ---------------------------------------------- 161 | 162 | # Grouping the document tree into Texinfo files. List of tuples 163 | # (source start file, target name, title, author, 164 | # dir menu entry, description, category) 165 | texinfo_documents = [ 166 | ( 167 | master_doc, 168 | "curvelops", 169 | "curvelops Documentation", 170 | author, 171 | "curvelops", 172 | "One line description of project.", 173 | "Miscellaneous", 174 | ), 175 | ] 176 | 177 | 178 | # -- Options for Epub output ------------------------------------------------- 179 | 180 | # Bibliographic Dublin Core info. 181 | epub_title = project 182 | 183 | # The unique identifier of the text. This can be a ISBN number 184 | # or the project homepage. 185 | # 186 | # epub_identifier = '' 187 | 188 | # A unique identification for the text. 189 | # 190 | # epub_uid = '' 191 | 192 | # A list of files that should not be packed into the epub file. 193 | epub_exclude_files = ["search.html"] 194 | 195 | 196 | # -- Extension configuration ------------------------------------------------- 197 | autodoc_typehints = "none" 198 | 199 | sphinx_gallery_conf = { 200 | "examples_dirs": "../../examples", # path to your example scripts 201 | "gallery_dirs": "gallery", # path to where to save gallery generated output 202 | "filename_pattern": r"\.py", 203 | # Remove the "Download all examples" button from the top level gallery 204 | "download_all_examples": False, 205 | # Sort gallery example by file name instead of number of lines (default) 206 | "within_subsection_order": ExampleTitleSortKey, 207 | # directory where function granular galleries are stored 208 | "backreferences_dir": "api/generated/backreferences", 209 | # Modules for which function level galleries are created. 210 | "doc_module": "curvelops", 211 | # Insert links to documentation of objects in the examples 212 | "reference_url": {"curvelops": None}, 213 | } 214 | # Always show the source code that generates a plot 215 | plot_include_source = True 216 | plot_formats = ["png"] 217 | # Sphinx project configuration 218 | templates_path = ["_templates"] 219 | exclude_patterns = ["_build", "**.ipynb_checkpoints", "**.ipynb", "**.md5"] 220 | source_suffix = ".rst" 221 | 222 | # Copybutton config 223 | copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " 224 | copybutton_prompt_is_regexp = True 225 | 226 | # Pydata config 227 | html_theme_options = { 228 | "github_url": "https://github.com/PyLops/curvelops", 229 | "external_links": [{"url": "https://github.com/PyLops/pylops", "name": "PyLops"}], 230 | "header_links_before_dropdown": 10, 231 | "show_toc_level": 2, 232 | } 233 | html_context = { 234 | "github_user": "PyLops", 235 | "github_repo": "curvelops", 236 | "github_version": "main", 237 | "doc_path": "docssrc", 238 | } 239 | -------------------------------------------------------------------------------- /docssrc/source/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Contributions are welcome! Please submit your pull-request, issue or comment 5 | in the `GitHub repo `__. You are also 6 | welcome to join the `PyLops slack channel `__. 7 | 8 | Installation for developers 9 | --------------------------- 10 | 11 | Developers should clone the 12 | `main `__ branch of the 13 | repository and install the dev requiments: 14 | 15 | .. code-block:: console 16 | 17 | $ git clone https://github.com/PyLops/curvelops 18 | $ git remote add upstream https://github.com/PyLops/curvelops 19 | $ make dev-install 20 | 21 | They should then follow the same instructions in the :ref:`Requirements` 22 | section. We recommend installing dependencies into a separate environment. 23 | Finally, they can install Curvelops with 24 | 25 | .. code-block:: console 26 | 27 | $ python3 -m pip install -e . 28 | 29 | Developers should also install `pre-commit `__ hooks with 30 | 31 | .. code-block:: console 32 | 33 | $ pre-commit install 34 | 35 | 36 | Developer workflow 37 | ------------------ 38 | 39 | Developers should start from a fresh copy of main with 40 | 41 | .. code-block:: console 42 | 43 | $ git checkout main 44 | $ git pull upstream main 45 | 46 | Before you start making changes, create a new branch with 47 | 48 | .. code-block:: console 49 | 50 | $ git checkout -b patch-some-cool-feature 51 | 52 | After implementing your cool feature (including tests 🤩), commit your changes 53 | to kick-off the pre-commit hooks. These will reject and "fix" your code by 54 | running the proper hooks. At this point, the user must check the changes and 55 | then stage them before trying to commit again. 56 | 57 | Once changes are committed, we encourage developers to lint, check types and 58 | build/check documentation with: 59 | 60 | .. code-block:: console 61 | 62 | $ make tests 63 | $ make lint 64 | $ make typeannot 65 | $ make coverage 66 | $ make doc 67 | $ make servedoc 68 | 69 | Once everything is in order, and your code has been pushed to GitHub, 70 | navigate to https://github.com/PyLops/curvelops and submit your PR! 71 | -------------------------------------------------------------------------------- /docssrc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. curvelops documentation master file, created by 2 | sphinx-quickstart on Sun Nov 15 14:04:06 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Overview 7 | ======== 8 | 9 | Curvelops is part of the PyLops ecossystem, an open-source Python library 10 | focused on providing a backend-agnostic, idiomatic, matrix-free library of 11 | linear operators and related computations. Curvelops provides 2D and 3D 12 | Curvelet transforms via `CurveLab `__. 13 | 14 | Visit :ref:`Installation` and then get started with the 15 | `Gallery `__ or browse the 16 | :ref:`API`. 17 | 18 | 19 | .. attention:: 20 | `CurveLab `__ is a proprietary library which must be 21 | sourced independently by the user. It is free for academic use. Curvelops 22 | contains no CurveLab code apart from function calls. 23 | 24 | .. note:: 25 | All CurveLab rights are reserved to Emmanuel Candes, Laurent Demanet, David 26 | Donoho and Lexing Ying. PyLops and Curvelops are not affiliated with 27 | CurveLab or its authors in any way. 28 | 29 | .. toctree:: 30 | :maxdepth: 1 31 | :hidden: 32 | 33 | self 34 | installation.rst 35 | modules.rst 36 | gallery/index.rst 37 | contributing.rst 38 | -------------------------------------------------------------------------------- /docssrc/source/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | .. _requirements: 7 | 8 | Requirements 9 | ------------ 10 | 11 | Installing Curvelops requires the following external components: 12 | 13 | * `FFTW `_ 2.1.5 14 | * `CurveLab `_ >= 2.0.2 15 | 16 | Both of these packages must be installed manually. 17 | 18 | Installing FFTW 19 | ~~~~~~~~~~~~~~~ 20 | Download and install with: 21 | 22 | 23 | .. code-block:: console 24 | 25 | $ wget https://www.fftw.org/fftw-2.1.5.tar.gz 26 | $ tar xvzf fftw-2.1.5.tar.gz 27 | $ mkdir -p /home/$USER/opt/ 28 | $ mv fftw-2.1.5/ /home/$USER/opt/ 29 | $ cd /home/$USER/opt/fftw-2.1.5/ 30 | $ ./configure --with-pic --prefix=/home/$USER/opt/fftw-2.1.5 --with-gcc=$(which gcc) 31 | $ make 32 | $ make install 33 | 34 | The ``--prefix`` and ``--with-gcc`` are optional and determine where it will 35 | install FFTW and where to find the GCC compiler, respectively. We recommend 36 | using the same compiler for FFTW and CurveLab. To ensure that FFTW has been 37 | installed correctly, run 38 | 39 | .. code-block:: console 40 | 41 | $ make check 42 | 43 | 44 | Installing CurveLab 45 | ~~~~~~~~~~~~~~~~~~~ 46 | After downloading the latest version of CurveLab, run 47 | 48 | .. code-block:: console 49 | 50 | $ tar xvzf CurveLab-2.1.3.tar.gz 51 | $ mkdir -p /home/$USER/opt/ 52 | $ mv CurveLab-2.1.3/ /home/$USER/opt/ 53 | $ cd /home/$USER/opt/CurveLab-2.1.3/ 54 | $ cp makefile.opt makefile.opt.bak 55 | 56 | In the file ``makefile.opt`` set ``FFTW_DIR``, ``CC`` and ``CXX`` variables. 57 | We recommend setting ``FFTW_DIR=/home/$USER/opt/fftw-2.1.5`` 58 | (or whatever directory was used in the ``--prefix`` option above), the output 59 | of ``which gcc`` in CC (or whatever compiler was used in ``--with-gcc``), and 60 | the ouput of ``which g++`` (or whatever C++ compiler is the equivalent of 61 | the selected ``CC`` compiler). Once the variables are set in `makefile.opt`, 62 | compile the library with 63 | 64 | .. code-block:: console 65 | 66 | $ cd /home/$USER/opt/CurveLab-2.1.3/ 67 | $ make clean 68 | $ make lib 69 | 70 | To ensure that CurveLab is installed correctly, run 71 | 72 | .. code-block:: console 73 | 74 | $ make test 75 | 76 | Installing Curvelops 77 | -------------------- 78 | 79 | Once FFTW and CurveLab are installed, install Curvelops with: 80 | 81 | .. code-block:: console 82 | 83 | $ export FFTW=/path/to/fftw-2.1.5 84 | $ export FDCT=/path/to/CurveLab-2.1.3 85 | $ python3 -m pip install git+https://github.com/PyLops/curvelops@0.23.4 86 | 87 | The ``FFTW`` variable is the same as ``FFTW_DIR`` as provided in the CurveLab 88 | installation. The ``FDCT`` variable points to the root of the CurveLab 89 | installation. 90 | -------------------------------------------------------------------------------- /docssrc/source/modules.rst: -------------------------------------------------------------------------------- 1 | .. _API: 2 | 3 | API 4 | === 5 | 6 | curvelops 7 | --------- 8 | 9 | .. automodule:: curvelops.curvelops 10 | :members: 11 | :undoc-members: 12 | :show-inheritance: 13 | 14 | plot 15 | ---- 16 | 17 | .. automodule:: curvelops.plot 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | typing 23 | ------ 24 | 25 | .. automodule:: curvelops.typing 26 | :members: 27 | :undoc-members: 28 | :show-inheritance: 29 | 30 | utils 31 | ----- 32 | 33 | .. automodule:: curvelops.utils 34 | :members: 35 | :undoc-members: 36 | :show-inheritance: 37 | -------------------------------------------------------------------------------- /docssrc/source/static/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyLops/curvelops/d6dc5fde8bcf399e57f81c5beb38449eb8863e45/docssrc/source/static/demo.png -------------------------------------------------------------------------------- /docssrc/source/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyLops/curvelops/d6dc5fde8bcf399e57f81c5beb38449eb8863e45/docssrc/source/static/logo.png -------------------------------------------------------------------------------- /docssrc/source/static/reconstruction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyLops/curvelops/d6dc5fde8bcf399e57f81c5beb38449eb8863e45/docssrc/source/static/reconstruction.png -------------------------------------------------------------------------------- /examples/README.rst: -------------------------------------------------------------------------------- 1 | Gallery 2 | ------- 3 | 4 | Below is a gallery of examples using curvelops. 5 | -------------------------------------------------------------------------------- /examples/plot_curvelets_in_fk.py: -------------------------------------------------------------------------------- 1 | r""" 2 | 4. Curvelet Coefficients in the FK domain 3 | ========================================= 4 | This example shows the regions in the FK domain where each 5 | curvelet coefficient occupies. 6 | """ 7 | # sphinx_gallery_thumbnail_number = 5 8 | 9 | # %% 10 | import matplotlib as mpl 11 | import matplotlib.pyplot as plt 12 | import numpy as np 13 | 14 | from curvelops import FDCT2D 15 | 16 | # %% 17 | # Setup 18 | # ===== 19 | 20 | # %% 21 | nx, nz = 301, 201 22 | data_empty = np.zeros((nx, nz)) 23 | 24 | # %% 25 | nbscales = 4 26 | nbangles_coarse = 8 27 | allcurvelets = False 28 | 29 | # %% 30 | Cop = FDCT2D( 31 | data_empty.shape, 32 | nbscales=nbscales, 33 | nbangles_coarse=nbangles_coarse, 34 | allcurvelets=allcurvelets, 35 | ) 36 | 37 | # %% 38 | empty_fdct = Cop @ data_empty 39 | 40 | # Convert to a curvelet struct indexed by 41 | # [scale, wedge (angle), z, x] 42 | empty_fdct_struct = Cop.struct(empty_fdct) 43 | 44 | # %% 45 | 46 | 47 | def create_dirac_wedge(Cop, scale, wedge): 48 | d = np.zeros(Cop.dims) 49 | wedge_only_fdct = Cop @ d 50 | 51 | wedge_only_fdct_struct = Cop.struct(wedge_only_fdct) 52 | normalization = np.sqrt(wedge_only_fdct_struct[scale][wedge].size) 53 | iz, ix = wedge_only_fdct_struct[scale][wedge].shape 54 | 55 | wedge_only_fdct_struct[scale][wedge][iz // 2, ix // 2] = normalization 56 | wedge_only_fdct = Cop.vect(wedge_only_fdct_struct) 57 | wedge_only = Cop.H @ wedge_only_fdct 58 | return wedge_only 59 | 60 | 61 | # %% 62 | # Plot Wedges of each Scale 63 | # ========================= 64 | 65 | # %% 66 | # Colormap to be used in all plots below 67 | fig, ax = plt.subplots(figsize=(6, 1)) 68 | col_map = plt.get_cmap("turbo") 69 | mpl.colorbar.ColorbarBase( 70 | ax, 71 | cmap=col_map, 72 | orientation="horizontal", 73 | norm=mpl.colors.Normalize(vmin=0, vmax=1), 74 | ) 75 | fig.tight_layout() 76 | 77 | # %% 78 | wedge_fk_abs = np.zeros_like(data_empty) 79 | for j, fdct_scale in enumerate(empty_fdct_struct, start=1): 80 | rows = int(np.floor(np.sqrt(len(fdct_scale)))) 81 | fig, axes = plt.subplots( 82 | int(np.ceil(len(fdct_scale) / rows)), 83 | rows, 84 | figsize=(5 * rows, 3 * rows), 85 | ) 86 | fig.suptitle( 87 | f"Scale {j} ({len(fdct_scale)} wedge{'s' if len(fdct_scale) > 1 else ''})" 88 | ) 89 | axes = np.atleast_1d(axes).ravel() 90 | wedge_scale_fk_abs = np.zeros_like(data_empty) 91 | for iw, (fdct_wedge, ax) in enumerate(zip(fdct_scale, axes), start=1): 92 | dirac_wedge = create_dirac_wedge(Cop, j - 1, iw - 1) 93 | dirac_wedge_fk = np.fft.fftshift( 94 | np.fft.fft2(np.fft.ifftshift(dirac_wedge), norm="ortho") 95 | ) 96 | wedge_scale_fk_abs += np.abs(dirac_wedge_fk) 97 | 98 | ax.imshow(np.abs(dirac_wedge_fk).T, cmap="turbo", vmin=0, vmax=1) 99 | if len(fdct_scale) > 1: 100 | ax.set(title=f"Wedge {iw}") 101 | ax.axis("off") 102 | fig.tight_layout() 103 | wedge_fk_abs += wedge_scale_fk_abs 104 | if len(fdct_scale) > 1: 105 | fig, ax = plt.subplots(figsize=(5, 3)) 106 | fig.suptitle(f"Scale {j} (sum of all wedges)") 107 | ax.imshow(wedge_scale_fk_abs.T, cmap="turbo", vmin=0, vmax=1) 108 | ax.axis("off") 109 | fig.tight_layout() 110 | 111 | fig, ax = plt.subplots(figsize=(5, 3)) 112 | fig.suptitle("Sum of all wedges of all scales)") 113 | ax.imshow(wedge_fk_abs.T, cmap="turbo", vmin=0, vmax=1) 114 | ax.axis("off") 115 | fig.tight_layout() 116 | 117 | # %% 118 | # Plot Dirac in Space domain 119 | # ========================== 120 | 121 | # %% 122 | dirac_all_fdct_struct = Cop.struct(empty_fdct.copy()) 123 | for fdct_scale in dirac_all_fdct_struct: 124 | for fdct_wedge in fdct_scale: 125 | normalization = np.sqrt(fdct_wedge.size) 126 | iz, ix = fdct_wedge.shape 127 | fdct_wedge[iz // 2, ix // 2] = normalization * (1 + 1j) 128 | fdct_wedge[iz // 2 + 1, ix // 2] = normalization * (1 + 1j) 129 | fdct_wedge[iz // 2, ix // 2 + 1] = normalization * (1 + 1j) 130 | fdct_wedge[iz // 2 + 1, ix // 2 + 1] = normalization * (1 + 1j) 131 | 132 | data_dirac = Cop.H @ Cop.vect(dirac_all_fdct_struct) 133 | data_dirac = (data_dirac.real + data_dirac.imag) / np.sqrt(2) 134 | vmax = 0.5 * np.sqrt(data_dirac.size) 135 | 136 | fig, ax = plt.subplots(figsize=(5, 3)) 137 | ax.imshow(data_dirac.T, cmap="gray", vmin=-vmax, vmax=vmax) 138 | ax.set( 139 | xlim=(nx // 2 - 30, nx // 2 + 30), 140 | ylim=(nz // 2 + 30, nz // 2 - 30), 141 | title="Space domain magnified", 142 | ) 143 | fig.tight_layout() 144 | -------------------------------------------------------------------------------- /examples/plot_seismic_regularization.py: -------------------------------------------------------------------------------- 1 | r""" 2 | 5. Seismic Regularization 3 | ========================= 4 | This example shows how to use the Curvelet transform to 5 | condition a missing-data seismic regularization problem. 6 | """ 7 | # sphinx_gallery_thumbnail_number = 2 8 | 9 | # %% 10 | import warnings 11 | 12 | warnings.filterwarnings("ignore") 13 | 14 | import matplotlib.pyplot as plt 15 | import numpy as np 16 | import pylops 17 | from pylops.optimization.sparsity import fista 18 | from scipy.signal import convolve 19 | 20 | from curvelops import FDCT2D 21 | 22 | np.random.seed(0) 23 | warnings.filterwarnings("ignore") 24 | 25 | # %% 26 | # Setup 27 | # ===== 28 | inputfile = "../testdata/seismic.npz" 29 | inputdata = np.load(inputfile) 30 | 31 | x = inputdata["R"][50, :, ::2] 32 | x = x / np.abs(x).max() 33 | taxis, xaxis = inputdata["t"][::2], inputdata["r"][0] 34 | 35 | par = {} 36 | par["nx"], par["nt"] = x.shape 37 | par["dx"] = inputdata["r"][0, 1] - inputdata["r"][0, 0] 38 | par["dt"] = inputdata["t"][1] - inputdata["t"][0] 39 | 40 | # Add wavelet 41 | wav = inputdata["wav"][::2] 42 | wav_c = np.argmax(wav) 43 | x = np.apply_along_axis(convolve, 1, x, wav, mode="full") 44 | x = x[:, wav_c:][:, : par["nt"]] 45 | 46 | # Gain 47 | gain = np.tile((taxis**2)[:, np.newaxis], (1, par["nx"])).T 48 | x *= gain 49 | 50 | # Subsampling locations 51 | perc_subsampling = 0.5 52 | Nsub = int(np.round(par["nx"] * perc_subsampling)) 53 | iava = np.sort(np.random.permutation(np.arange(par["nx"]))[:Nsub]) 54 | 55 | # Restriction operator 56 | Rop = pylops.Restriction((par["nx"], par["nt"]), iava, axis=0, dtype="float64") 57 | 58 | y = Rop @ x 59 | xadj = Rop.H @ y 60 | 61 | # Apply mask 62 | ymask = Rop.mask(x) 63 | 64 | # %% 65 | # Curvelet transform 66 | # ================== 67 | 68 | # %% 69 | DCTOp = FDCT2D((par["nx"], par["nt"]), nbscales=4) 70 | 71 | yc = DCTOp @ x 72 | xcadj = DCTOp.H @ yc 73 | 74 | # %% 75 | opts_plot = dict( 76 | cmap="gray", 77 | vmin=-0.1, 78 | vmax=0.1, 79 | extent=(xaxis[0], xaxis[-1], taxis[-1], taxis[0]), 80 | ) 81 | 82 | fig, axs = plt.subplots(1, 2, sharey=True, figsize=(10, 7)) 83 | axs[0].imshow(x.T, **opts_plot) 84 | axs[0].set_title("Data") 85 | axs[0].axis("tight") 86 | axs[1].imshow(np.real(xcadj).T, **opts_plot) 87 | axs[1].set_title("Adjoint curvelet") 88 | axs[1].axis("tight") 89 | 90 | # %% 91 | # Reconstruction based on Curvelet transform 92 | # ########################################## 93 | 94 | # %% 95 | # Combined modelling operator 96 | RCop = Rop @ DCTOp.H 97 | RCop.dims = (RCop.shape[1],) # flatten 98 | RCop.dimsd = (RCop.shape[0],) 99 | 100 | # Inverse 101 | pl1, _, cost = fista(RCop, y.ravel(), niter=100, eps=1e-3, show=True) 102 | xl1 = (DCTOp.H @ pl1).real.reshape(x.shape) 103 | 104 | # %% 105 | fig, axs = plt.subplots(1, 4, sharey=True, figsize=(16, 7)) 106 | axs[0].imshow(x.T, **opts_plot) 107 | axs[0].set_title("Data") 108 | axs[0].axis("tight") 109 | axs[1].imshow(ymask.T, **opts_plot) 110 | axs[1].set_title("Masked data") 111 | axs[1].axis("tight") 112 | axs[2].imshow(xl1.T, **opts_plot) 113 | axs[2].set_title("Reconstructed data") 114 | axs[2].axis("tight") 115 | axs[3].imshow((x - xl1).T, **opts_plot) 116 | axs[3].set_title("Reconstruction error") 117 | axs[3].axis("tight") 118 | 119 | # %% 120 | fig, ax = plt.subplots(figsize=(16, 2)) 121 | ax.plot(range(1, len(cost) + 1), cost, "k") 122 | ax.set(xlim=[1, len(cost)]) 123 | fig.suptitle("FISTA convergence") 124 | -------------------------------------------------------------------------------- /examples/plot_sigmoid.py: -------------------------------------------------------------------------------- 1 | r""" 2 | 2. Sigmoid Example 3 | ================== 4 | This example shows the effectiveness of curvelets in describing a typical 5 | subsurface structure. It compares the Curvelet transform with the Wavelet 6 | and Seislet transforms. 7 | """ 8 | # sphinx_gallery_thumbnail_number = 3 9 | 10 | # %% 11 | import matplotlib.pyplot as plt 12 | import numpy as np 13 | import pylops 14 | 15 | from curvelops import FDCT2D 16 | 17 | try: 18 | # Progress bars 19 | from tqdm.notebook import tqdm 20 | except ImportError: 21 | 22 | def tqdm(x): 23 | return x 24 | 25 | print("Try out tqdm for progress bars!") 26 | 27 | # %% 28 | # Input data 29 | # ========== 30 | 31 | # %% 32 | inputfile = "../testdata/sigmoid.npz" 33 | 34 | d = np.load(inputfile) 35 | d = d["sigmoid"] 36 | nx, nt = d.shape 37 | dx, dt = 8, 0.004 38 | x, t = np.arange(nx) * dx, np.arange(nt) * dt 39 | 40 | # %% 41 | clip = 0.5 * np.max(np.abs(d)) 42 | opts = dict( 43 | aspect="auto", 44 | extent=(x[0], x[-1], t[-1], t[0]), 45 | vmin=-clip, 46 | vmax=clip, 47 | cmap="gray", 48 | interpolation="nearest", 49 | ) 50 | 51 | fig, ax = plt.subplots(figsize=(8, 6), sharey=True, sharex=True) 52 | ax.imshow(d.T, **opts) 53 | ax.set(xlabel="Position [m]", ylabel="Time [s]", title="Data") 54 | fig.tight_layout() 55 | 56 | # %% 57 | # Sparsifying Transforms 58 | # ====================== 59 | # * Seislet 60 | # * Wavelet 61 | # * Curvelet 62 | 63 | # %% 64 | 65 | # Seislet 66 | slope = -pylops.utils.signalprocessing.slope_estimate(d.T, dt, dx, smooth=6)[0] 67 | Sop = pylops.signalprocessing.Seislet(slope.T, sampling=(dx, dt)) 68 | Sop.shape 69 | 70 | # %% 71 | 72 | # Wavelet 73 | Wop = pylops.signalprocessing.Seislet(np.zeros_like(slope.T), sampling=(dx, dt)) 74 | Wop.shape 75 | 76 | # %% 77 | 78 | # Curvelet 79 | Cop = FDCT2D(d.shape) 80 | Cop.shape 81 | 82 | # %% 83 | 84 | 85 | def reconstruct(data, op, perc=0.1): 86 | """ 87 | Convenience function to calculate reconstruction using top 88 | `perc` percent of coefficients of a given operator `op`. 89 | """ 90 | y = op * data.ravel() 91 | denoise = np.zeros_like(y) 92 | 93 | # Order coefficients by strength 94 | strong_idx = np.argsort(-np.abs(y)) 95 | strong = np.abs(y)[strong_idx] 96 | 97 | # Select only top `perc`% coefficients 98 | strong_idx = strong_idx[: int(np.rint(len(strong_idx) * perc))] 99 | denoise[strong_idx] = y[strong_idx] 100 | 101 | data_denoise = op.inverse(denoise).reshape(data.shape) 102 | return data_denoise.real, strong 103 | 104 | 105 | # %% 106 | 107 | # Reconstruct data with only 10% of the strongest coefficients in sparse domain 108 | perc = 0.1 109 | d_seis, seis_strong = reconstruct(d, Sop, perc=perc) 110 | d_dwt, dwt_strong = reconstruct(d, Wop, perc=perc) 111 | d_dct, dct_strong = reconstruct(d, Cop, perc=perc) 112 | 113 | # %% 114 | fig, ax = plt.subplots() 115 | ax.semilogy( 116 | np.linspace(0, 100, len(seis_strong), endpoint=True), 117 | seis_strong / seis_strong[0], 118 | label="Seislet", 119 | ) 120 | ax.semilogy( 121 | np.linspace(0, 100, len(dwt_strong), endpoint=True), 122 | dwt_strong / dwt_strong[0], 123 | label="Wavelet", 124 | ) 125 | ax.semilogy( 126 | np.linspace(0, 100, len(dct_strong), endpoint=True), 127 | dct_strong / dct_strong[0], 128 | label="Curvelet", 129 | ) 130 | ax.set( 131 | xlim=(0, 100), 132 | ylim=(1e-4, 1), 133 | xlabel="Coefficients [%]", 134 | ylabel="Coefficient strength [dB]", 135 | title="Transform Coefficients", 136 | ) 137 | ax.axvline(100 * perc, color="k", label=f"{100*perc:.0f}%") 138 | ax.legend() 139 | fig.tight_layout() 140 | 141 | # %% 142 | gain = 4 143 | fig, ax = plt.subplots(2, 3, figsize=(14, 8), sharey=True, sharex=True) 144 | for i, (d_trans, title) in enumerate( 145 | zip([d_seis, d_dwt, d_dct], ["Seislet", "Wavelet", "Curvelet"]) 146 | ): 147 | ax[0, i].imshow(d_trans.T, **opts) 148 | im = ax[1, i].imshow((d - d_trans).T, **opts) 149 | im.set_clim(vmin=-clip / gain, vmax=clip / gain) 150 | ax[0, i].set(title=f"{title} ({100*perc:.0f}% of components)") 151 | ax[1, i].set(title=f"{title} Error x {gain}", xlabel="Position [m]") 152 | ax[0, 0].set(ylabel="Time [s]") 153 | ax[1, 0].set(ylabel="Time [s]") 154 | fig.tight_layout() 155 | 156 | # %% 157 | 158 | # Calculate error in reconstruction by number of coefficients used 159 | error_seis = [] 160 | error_dwt = [] 161 | error_dct = [] 162 | for perc in tqdm(2 ** np.arange(7) / 100.0): 163 | d_seis = reconstruct(d, Sop, perc=perc)[0] 164 | d_dwt = reconstruct(d, Wop, perc=perc)[0] 165 | d_dct = reconstruct(d, Cop, perc=perc)[0] 166 | error_seis.append(np.linalg.norm(d_seis - d)) 167 | error_dwt.append(np.linalg.norm(d_dwt - d)) 168 | error_dct.append(np.linalg.norm(d_dct - d)) 169 | 170 | # %% 171 | fig, ax = plt.subplots() 172 | ax.semilogy(2 ** np.arange(7), error_seis, "o-", label="Seislet") 173 | ax.semilogy(2 ** np.arange(7), error_dwt, "o-", label="Wavelet") 174 | ax.semilogy(2 ** np.arange(7), error_dct, "o-", label="Curvelet") 175 | ax.set(xlabel="Percentage of coefficients", ylabel=r"Error ($L_2$ norm)") 176 | ax.legend() 177 | fig.tight_layout() 178 | -------------------------------------------------------------------------------- /examples/plot_sigmoid_coefficients.py: -------------------------------------------------------------------------------- 1 | r""" 2 | 3. Visualizing Curvelet Coefficients 3 | ==================================== 4 | This example shows the how to visualize curvelet coefficients of an image, 5 | using as example a typical subsurface structure. 6 | """ 7 | # sphinx_gallery_thumbnail_number = 3 8 | 9 | # %% 10 | import matplotlib.pyplot as plt 11 | import numpy as np 12 | 13 | from curvelops import FDCT2D, apply_along_wedges, curveshow 14 | 15 | # %% 16 | # Input data 17 | # ========== 18 | 19 | # %% 20 | inputfile = "../testdata/sigmoid.npz" 21 | 22 | d = np.load(inputfile) 23 | d = d["sigmoid"] 24 | nx, nt = d.shape 25 | dx, dt = 0.005, 0.004 26 | x, t = np.arange(nx) * dx, np.arange(nt) * dt 27 | 28 | # %% 29 | aspect = dt / dx 30 | opts_plot = dict( 31 | extent=(x[0], x[-1], t[-1], t[0]), 32 | cmap="gray", 33 | interpolation="lanczos", 34 | aspect=aspect, 35 | ) 36 | vmax = 0.5 * np.max(np.abs(d)) 37 | figsize_aspect = aspect * nt / nx 38 | fig, ax = plt.subplots(figsize=(8, figsize_aspect * 8), sharey=True, sharex=True) 39 | ax.imshow(d.T, vmin=-vmax, vmax=vmax, **opts_plot) 40 | ax.set(xlabel="Position [m]", ylabel="Time [s]", title=f"Data shape {d.shape}") 41 | fig.tight_layout() 42 | 43 | # %% 44 | # Create Curvelet Transform 45 | # ========================= 46 | nbscales = 4 47 | nbangles_coarse = 8 48 | allcurvelets = False # Last scale will be a wavelet transform 49 | 50 | # %% 51 | Cop = FDCT2D( 52 | d.shape, 53 | nbscales=nbscales, 54 | nbangles_coarse=nbangles_coarse, 55 | allcurvelets=allcurvelets, 56 | ) 57 | 58 | # %% 59 | # Convert to a list of lists of ndarrays. 60 | d_fdct_struct = Cop.struct(Cop @ d) 61 | 62 | # %% 63 | # Real part of FDCT coefficients 64 | # ============================== 65 | # Curvelet coefficients are essentially directionally-filtered, shrunk versions 66 | # of the original signal. Note that the "shrinking" does not preserve aspect 67 | # ratio. 68 | 69 | # %% 70 | for j, c_scale in enumerate(d_fdct_struct, start=1): 71 | nangles = len(c_scale) 72 | rows = int(np.floor(np.sqrt(nangles))) 73 | cols = int(np.ceil(nangles / rows)) 74 | fig, axes = plt.subplots( 75 | rows, 76 | cols, 77 | figsize=(5 * rows, figsize_aspect * 5 * rows), 78 | ) 79 | fig.suptitle(f"Scale {j} ({len(c_scale)} wedge{'s' if len(c_scale) > 1 else ''})") 80 | axes = np.atleast_1d(axes).ravel() 81 | vmax = 0.5 * max(np.abs(Cweg).max() for Cweg in c_scale) 82 | for iw, (fdct_wedge, ax) in enumerate(zip(c_scale, axes), start=1): 83 | # Note that wedges are transposed in comparison to the input vector. 84 | # This is due to the underlying implementation of the transform. In 85 | # order to plot in the same manner as the data, we must first 86 | # transpose the wedge. We will using the transpose of the wedge for 87 | # visualization. 88 | c = fdct_wedge.real.T 89 | ax.imshow(c.T, vmin=-vmax, vmax=vmax, **opts_plot) 90 | ax.set(title=f"Wedge {iw} shape {c.shape}") 91 | ax.axis("off") 92 | fig.tight_layout() 93 | 94 | # %% 95 | # Imaginagy part of FDCT coefficients 96 | # =================================== 97 | # Curvelops includes much of the above logic wrapped in the following 98 | # :py:class:`curvelops.plot.cuveshow`. Since we 99 | 100 | # Normalize each coefficient by max abs 101 | y_norm = apply_along_wedges(d_fdct_struct, lambda w, *_: w / np.abs(w).max()) 102 | 103 | # %% 104 | figs = curveshow( 105 | y_norm, 106 | real=False, 107 | kwargs_imshow={**opts_plot, "vmin": -0.5, "vmax": 0.5}, 108 | ) 109 | -------------------------------------------------------------------------------- /examples/plot_sigmoid_disks.py: -------------------------------------------------------------------------------- 1 | r""" 2 | 6. Multiscale Local Directions 3 | ============================== 4 | This example shows how to use the Curvelet transform to 5 | visualize local, multiscale preferrential directions in 6 | an image. Inspired by `Kymatio's Scattering disks `__. 7 | """ 8 | # sphinx_gallery_thumbnail_number = 3 9 | 10 | # %% 11 | import matplotlib as mpl 12 | import matplotlib.pyplot as plt 13 | import numpy as np 14 | import numpy.typing as npt 15 | from mpl_toolkits.axes_grid1 import make_axes_locatable 16 | from pylops.signalprocessing import FFT2D 17 | 18 | from curvelops import FDCT2D 19 | from curvelops.plot import ( 20 | create_axes_grid, 21 | create_inset_axes_grid, 22 | overlay_arrows, 23 | overlay_disks, 24 | ) 25 | from curvelops.utils import array_split_nd, ndargmax 26 | 27 | # %% 28 | # Input 29 | # ===== 30 | 31 | # %% 32 | inputfile = "../testdata/sigmoid.npz" 33 | 34 | data = np.load(inputfile) 35 | data = data["sigmoid"] 36 | nx, nz = data.shape 37 | dx, dz = 0.005, 0.004 38 | x, z = np.arange(nx) * dx, np.arange(nz) * dz 39 | 40 | 41 | # %% 42 | aspect = dz / dx 43 | figsize_aspect = aspect * nz / nx 44 | opts_space = dict( 45 | extent=(x[0], x[-1], z[-1], z[0]), 46 | cmap="gray", 47 | interpolation="lanczos", 48 | aspect=aspect, 49 | ) 50 | vmax = 0.5 * np.max(np.abs(data)) 51 | fig, ax = plt.subplots(figsize=(8, figsize_aspect * 8)) 52 | ax.imshow(data.T, vmin=-vmax, vmax=vmax, **opts_space) 53 | ax.set(xlabel="Position [km]", ylabel="Depth [km]", title="Data") 54 | fig.tight_layout() 55 | 56 | 57 | # %% 58 | # Understanding Curvelet Disks 59 | # ============================ 60 | 61 | # %% 62 | # First we create and apply curvelet transform. 63 | Cop = FDCT2D(data.shape, nbscales=4, nbangles_coarse=8, allcurvelets=False) 64 | d_c = Cop.struct(Cop @ data) 65 | 66 | # %% 67 | # Each wedge is mapped to a region of the scattering disk. 68 | # The first number refers to the scale, the second to the wedge index, 69 | # zero-indexed. 70 | # 71 | # The disks have the most energy in the direction perpendicular to the 72 | # directions of minimum change. The following disk is computed with the entire 73 | # image. We observe that with energy mostly along the top-bottom direction, 74 | # the directions in the image will be mostly along the left-right direction, 75 | # which matches the input data. 76 | rows, cols = 1, 1 77 | fig, axes = create_axes_grid( 78 | rows, 79 | cols, 80 | kwargs_subplots=dict(projection="polar"), 81 | kwargs_figure=dict(figsize=(4, 4)), 82 | ) 83 | overlay_disks(d_c, axes, annotate=True) 84 | 85 | 86 | # %% 87 | # Multiscale Local Directions 88 | # ============================ 89 | # The power of the curvelet transform is to provide dip information varying 90 | # with location and scale. 91 | # Below we will compute preferrential local directions using an approach 92 | # based on the 2D FFT that does not differentiate between scales. 93 | 94 | # %% 95 | rows, cols = 5, 6 96 | 97 | 98 | def local_single_scale_dips(data: npt.NDArray, rows: int, cols: int) -> npt.NDArray: 99 | kvecs = np.empty((rows, cols, 2)) 100 | d_split = array_split_nd(data.T, rows, cols) 101 | 102 | for irow in range(kvecs.shape[0]): 103 | for icol in range(kvecs.shape[1]): 104 | d_loc = d_split[irow][icol].T 105 | Fop_loc = FFT2D( 106 | d_loc.shape, 107 | sampling=[dx, dz], 108 | norm="ortho", 109 | real=False, 110 | ifftshift_before=True, 111 | fftshift_after=True, 112 | engine="scipy", 113 | ) 114 | d_k_loc = Fop_loc @ d_loc 115 | 116 | kx_loc = Fop_loc.f1 117 | kz_loc = Fop_loc.f2 118 | 119 | kx_locmax, kz_locmax = ndargmax(np.abs(d_k_loc[:, kz_loc > 0])) 120 | 121 | k = np.array([kx_loc[kx_locmax], kz_loc[kz_loc > 0][kz_locmax]]) 122 | kvecs[irow, icol, :] = k / np.linalg.norm(k) 123 | return kvecs 124 | 125 | 126 | # %% 127 | diskcmap = "turbo" 128 | rows, cols = 5, 6 129 | kvecs = local_single_scale_dips(data, rows, cols) 130 | kvecs *= 0.15 * min(x[-1] - x[0], z[-1] - z[0]) 131 | 132 | fig, ax = plt.subplots(figsize=(8, figsize_aspect * 8)) 133 | ax.imshow(data.T, vmin=-vmax, vmax=vmax, **opts_space) 134 | ax.set(xlabel="Position [km]", ylabel="Depth [km]") 135 | divider = make_axes_locatable(ax) 136 | cax = divider.append_axes("right", size="5%", pad=0.1) 137 | mpl.colorbar.ColorbarBase( 138 | cax, 139 | cmap=plt.get_cmap(diskcmap), 140 | norm=mpl.colors.Normalize(vmin=0, vmax=1), 141 | alpha=0.8, 142 | ) 143 | 144 | # Local single-scale directions 145 | overlay_arrows(kvecs, ax) 146 | 147 | # Local multsicale directions 148 | axesin = create_inset_axes_grid( 149 | ax, 150 | rows, 151 | cols, 152 | height=0.6, 153 | width=0.6, 154 | kwargs_inset_axes=dict(projection="polar"), 155 | ) 156 | overlay_disks(d_c, axesin, linewidth=0.0, cmap=diskcmap) 157 | fig.tight_layout() 158 | -------------------------------------------------------------------------------- /examples/plot_single_curvelet.py: -------------------------------------------------------------------------------- 1 | r""" 2 | 1. Visualize a Single Curvelet 3 | ============================== 4 | This example shows a single curvelet coefficient in 5 | spatial and frequency domains. 6 | """ 7 | 8 | # %% 9 | import matplotlib.pyplot as plt 10 | import numpy as np 11 | from matplotlib.ticker import MultipleLocator 12 | 13 | from curvelops import FDCT2D 14 | from curvelops.plot import create_colorbar 15 | 16 | # %% 17 | # Setup 18 | # ===== 19 | m = 512 20 | n = 512 21 | x = np.zeros((m, n)) 22 | DCT = FDCT2D(x.shape) 23 | 24 | # %% 25 | # Curvelet Domain 26 | # =============== 27 | 28 | # %% 29 | y = DCT * x 30 | 31 | # Convert to a curvelet struct indexed by 32 | # [scale, wedge (angle), x, y] 33 | y_reshape = DCT.struct(y) 34 | 35 | # %% 36 | # Select single curvelet 37 | # ====================== 38 | s = 4 39 | w = 0 40 | a, b = y_reshape[s][w].shape 41 | normalization = np.sqrt(y_reshape[s][w].size) 42 | y_reshape[s][w][a // 2, b // 2] = 1 * normalization 43 | y_reshape[s][w + len(y_reshape[s]) // 2][a // 2, b // 2] = -1j * normalization 44 | 45 | y = DCT.vect(y_reshape) 46 | 47 | # %% 48 | # Perform adjoint transform and reshape 49 | # ===================================== 50 | x = DCT.H @ y 51 | 52 | # %% 53 | # F-K domain 54 | # ========== 55 | x_fk = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(x), norm="ortho")) 56 | 57 | # %% 58 | # Visualize 59 | # ========= 60 | vmin, vmax = 0.8 * np.array([-1, 1]) * np.abs(np.max(x)) 61 | fig, ax = plt.subplots(2, 2, figsize=(8, 8), sharex="row", sharey="row") 62 | 63 | im = ax[0, 0].imshow(x.real.T, cmap="gray", vmin=vmin, vmax=vmax) 64 | create_colorbar(im, ax[0, 0]) 65 | 66 | im = ax[0, 1].imshow(x.imag.T, cmap="gray", vmin=vmin, vmax=vmax) 67 | create_colorbar(im, ax[0, 1]) 68 | 69 | im = ax[1, 0].imshow(np.abs(x_fk).T, cmap="turbo", vmin=0) 70 | create_colorbar(im, ax[1, 0]) 71 | 72 | mask = np.abs(x_fk) > 0.01 * np.abs(x_fk).max() 73 | im = ax[1, 1].imshow( 74 | (mask * np.angle(x_fk, deg=True)).T, 75 | cmap="twilight_shifted", 76 | vmin=-180, 77 | vmax=180, 78 | ) 79 | cax, cb = create_colorbar(im, ax[1, 1]) 80 | cax.get_yaxis().set_major_locator(MultipleLocator(45)) 81 | 82 | 83 | ax[0, 0].set( 84 | xlim=(m // 2 - 50, m // 2 + 50), 85 | ylim=(n // 2 - 50, n // 2 + 50), 86 | title="Space domain (Real) magnified", 87 | ) 88 | ax[0, 1].set(title="Space domain (Imag) magnified") 89 | ax[1, 0].set(title="Frequency domain (Abs)") 90 | ax[1, 1].set(title="Frequency domain (Phase)") 91 | fig.tight_layout() 92 | -------------------------------------------------------------------------------- /notebooks/Single_Curvelet_Interactive.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "attachments": {}, 5 | "cell_type": "markdown", 6 | "metadata": {}, 7 | "source": [ 8 | "# Single Curvelet (Interactive)\n", 9 | "This interactive example shows a single curvelet coefficient in\n", 10 | "spatial and frequency domains.\n" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": 1, 16 | "metadata": {}, 17 | "outputs": [], 18 | "source": [ 19 | "import matplotlib.pyplot as plt\n", 20 | "import numpy as np\n", 21 | "from IPython.display import display\n", 22 | "from ipywidgets import HBox, IntSlider, VBox, interactive_output\n", 23 | "\n", 24 | "from curvelops import FDCT2D" 25 | ] 26 | }, 27 | { 28 | "cell_type": "markdown", 29 | "metadata": {}, 30 | "source": [ 31 | "### Setup" 32 | ] 33 | }, 34 | { 35 | "cell_type": "code", 36 | "execution_count": 2, 37 | "metadata": {}, 38 | "outputs": [], 39 | "source": [ 40 | "nx = 300\n", 41 | "nz = 350\n", 42 | "\n", 43 | "# Create operator\n", 44 | "DCT = FDCT2D((nx, nz), nbangles_coarse=8)\n", 45 | "\n", 46 | "# Create empty structure for curvelet\n", 47 | "y_struct = DCT.struct(np.zeros(DCT.shape[0]))" 48 | ] 49 | }, 50 | { 51 | "cell_type": "markdown", 52 | "metadata": {}, 53 | "source": [ 54 | "### Plotting" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": 3, 60 | "metadata": {}, 61 | "outputs": [], 62 | "source": [ 63 | "def display_curvelet(scale=1, wedge=1, ix=1, iy=1):\n", 64 | " s = scale - 1\n", 65 | " w = wedge - 1\n", 66 | "\n", 67 | " # Populate curvelet\n", 68 | " y_new = DCT.struct(np.zeros(DCT.shape[0]))\n", 69 | " A, B = y_new[s][w].shape\n", 70 | " iy = max(1, min(iy, A))\n", 71 | " ix = max(1, min(ix, B))\n", 72 | " y_new[s][w][iy - 1, ix - 1] = 1.0\n", 73 | "\n", 74 | " x = DCT.H @ DCT.vect(y_new)\n", 75 | "\n", 76 | " x_fk = np.fft.fft2(x)\n", 77 | " x_fk = np.fft.fftshift(x_fk)\n", 78 | "\n", 79 | " vmin, vmax = 0.8 * np.array([-1, 1]) * np.abs(np.max(x))\n", 80 | " fig, ax = plt.subplots(2, 2, figsize=(8, 8), sharex=\"row\", sharey=\"row\")\n", 81 | " ax[0, 0].imshow(np.real(x.T), cmap=\"gray\", vmin=vmin, vmax=vmax)\n", 82 | " ax[0, 1].imshow(np.imag(x.T), cmap=\"gray\", vmin=vmin, vmax=vmax)\n", 83 | " ax[1, 0].imshow(np.abs(x_fk.T), cmap=\"turbo\", vmin=0)\n", 84 | " mask = np.abs(x_fk) > 0.01 * np.abs(x_fk).max()\n", 85 | " ax[1, 1].imshow(\n", 86 | " (mask * np.angle(x_fk, deg=True)).T,\n", 87 | " cmap=\"twilight_shifted\",\n", 88 | " vmin=-180,\n", 89 | " vmax=180,\n", 90 | " )\n", 91 | " ax[0, 0].set(title=\"Space domain (Real)\")\n", 92 | " ax[0, 1].set(title=\"Space domain (Imag)\")\n", 93 | " ax[1, 0].set(title=\"Frequency domain (Abs)\")\n", 94 | " ax[1, 1].set(title=\"Frequency domain (Phase)\")\n", 95 | " ax[0, 0].axvline(nx / 2, color=\"y\", alpha=0.5)\n", 96 | " ax[0, 0].axhline(nz / 2, color=\"y\", alpha=0.5)\n", 97 | " ax[0, 1].axvline(nx / 2, color=\"y\", alpha=0.5)\n", 98 | " ax[0, 1].axhline(nz / 2, color=\"y\", alpha=0.5)\n", 99 | " fig.tight_layout()" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": 4, 105 | "metadata": {}, 106 | "outputs": [ 107 | { 108 | "data": { 109 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAAsIAAAMWCAYAAAD7yuBUAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjYuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/P9b71AAAACXBIWXMAAA9hAAAPYQGoP6dpAAD3PklEQVR4nOydeZgVxbn/v2f2GYYZdgZkEXFhxwQV5xqXCIpIXElcYiIucUnAXCVGQxZRTILXGDUa1Bi9YBKMS4x69WfcECUqEkWNuwGDAZcBBQdkmb1+f4zVU6emejunz+nu09/P88wz5/Tpfqt6e+vtb71VnRJCCBBCCCGEEJIwisKuACGEEEIIIWHAQJgQQgghhCQSBsKEEEIIISSRMBAmhBBCCCGJhIEwIYQQQghJJAyECSGEEEJIImEgTAghhBBCEgkDYUIIIYQQkkgYCBNCCCGEkETCQJhEjsMOOwyHHXZY2NVw5IwzzsDuu+8euN177rkHffr0wfbt2wO3nQn6fm7evBk9evTAI488El6lCCGRgf46Ov7aK2+99RZKSkrwxhtvhF2VSMBAOOK8/vrr+PrXv47hw4ejoqICu+22G4444gjceOONYVeNBEx7ezvmz5+PCy64ANXV1dby3XffHalUyvrr0aMHDjjgAPzhD3/Iex379u2L73znO/jZz36W97IJiTr018nByV9/7WtfC7Fm7owZMwYzZszAZZddFnZVIkFKCCHCrgQx8/zzz+OrX/0qhg0bhlmzZqGurg4bNmzACy+8gPfeew9r164Nu4o5QaoLTz/9dKj1cKK1tRUdHR0oLy8PzOYDDzyAE088ERs2bMBuu+1mLd99993Ru3dv/OAHPwAAfPzxx7jtttvwr3/9C7feeivOOeecwOqgc8YZZ+Dpp5/G+++/by17++23MWbMGCxbtgyHH354zsomJE7QXz8daj2cyLe/HjduHB5++OHAysoFf/vb33D00Udj7dq1GDlyZNjVCZWSsCtA7PnFL36B2tpavPjii+jVq1fab5s2bQqnUgQAUFpaGrjNxYsX46CDDkpzqpLddtsN3/rWt6zvZ5xxBvbYYw9cd911OQ2ETYwePRrjxo3DkiVLGAgT8gX019El3/46DkydOhW9e/fGHXfcgQULFoRdnVBhakSEee+99zB27NhuThUABgwYkPY9lUphzpw5WLp0KfbZZx9UVFRg0qRJWLFiRdp6//nPf/C9730P++yzDyorK9G3b1984xvfSFP8JI2Njbjooouw++67o7y8HEOGDMHpp5+OTz/91FqnubkZ8+fPx5577ony8nIMHToUl1xyCZqbmz3t46233oqRI0eisrISBxxwAP7+978b19u0aRPOPvtsDBw4EBUVFZg4cSLuuOOOtHXef/99pFIpXHPNNVi0aBH22GMPVFVV4cgjj8SGDRsghMCVV16JIUOGoLKyEscddxy2bNmSZuPBBx/EjBkzMHjwYJSXl2PkyJG48sor0d7enraennOmli33qby8HPvvvz9efPFF1+PQ1NSERx99FFOnTvV03Pr3749Ro0bhvffeS1ve0dGB66+/HmPHjkVFRQUGDhyI8847D5999llG+2nHEUccgYceegjsUCKkE/rrLuivw9lPAFYZ6jky5XGXlpbisMMOw4MPPui6D4UOFeEIM3z4cKxcuRJvvPEGxo0b57r+M888g7vvvhvf//73UV5ejptuuglHHXUU/vGPf1jbv/jii3j++edxyimnYMiQIXj//fdx880347DDDsNbb72FqqoqAMD27dtx8MEH4+2338ZZZ52FL3/5y/j000/xf//3f/jggw/Qr18/dHR04Nhjj8Wzzz6Lc889F6NHj8brr7+O6667Dv/617/wwAMPONb39ttvx3nnnYf/+q//woUXXoh///vfOPbYY9GnTx8MHTrUWm/Xrl047LDDsHbtWsyZMwcjRozAvffeizPOOAONjY347//+7zS7S5cuRUtLCy644AJs2bIFV199NU466SQcfvjhePrpp3HppZdi7dq1uPHGG3HxxRfjf//3f61tlyxZgurqasydOxfV1dV46qmncNlll2Hbtm341a9+5XoO7rzzTnz++ec477zzkEqlcPXVV+PEE0/Ev//9b0dVYvXq1WhpacGXv/xl1zIAoK2tDR988AF69+6dtvy8887DkiVLcOaZZ+L73/8+1q1bh9/+9rd45ZVX8Nxzz1l1yHY/J02ahOuuuw5vvvmmp2uTkEKH/roT+msz+djPm2++GXPmzMHBBx+Miy66CO+//z6OP/549O7dG0OGDOlWp0mTJuHBBx/Etm3bUFNT43lfCg5BIsvjjz8uiouLRXFxsaivrxeXXHKJeOyxx0RLS0u3dQEIAOKll16ylv3nP/8RFRUV4oQTTrCW7dy5s9u2K1euFADEH/7wB2vZZZddJgCIv/71r93W7+joEEII8cc//lEUFRWJv//972m/33LLLQKAeO6552z3raWlRQwYMEDsu+++orm52Vp+6623CgDi0EMPtZZdf/31AoD405/+lLZ9fX29qK6uFtu2bRNCCLFu3ToBQPTv3180NjZa686bN08AEBMnThStra3W8lNPPVWUlZWJpqYmx+Nz3nnniaqqqrT1Zs2aJYYPH259l2X37dtXbNmyxVr+4IMPCgDioYcesj0WQghx2223CQDi9ddf7/bb8OHDxZFHHik++eQT8cknn4jXX39dfPvb3xYAxOzZs631/v73vwsAYunSpWnbP/roo92WZ7qfkueff14AEHfffbfjfhGSFOivO6G/Hi5mzJjRraxc72dzc7Po27ev2H///dPsLVmypNs5ktx5550CgFi1apXj/hY6TI2IMEcccQRWrlyJY489Fv/85z9x9dVXY9q0adhtt93wf//3f93Wr6+vx6RJk6zvw4YNw3HHHYfHHnvM6kKprKy0fm9tbcXmzZux5557olevXnj55Zet3+677z5MnDgRJ5xwQrdyUqkUAODee+/F6NGjMWrUKHz66afWn8wbXb58ue2+vfTSS9i0aRPOP/98lJWVWcvPOOMM1NbWpq37yCOPoK6uDqeeeqq1rLS0FN///vexfft2PPPMM2nrf+Mb30izMXnyZADAt771LZSUlKQtb2lpwYcffmgtU4/P559/jk8//RQHH3wwdu7ciXfeecd2fyQnn3xymkp78MEHAwD+/e9/O263efNmAOim8Eoef/xx9O/fH/3798f48ePxxz/+EWeeeWaaGnDvvfeitrYWRxxxRNr5mDRpEqqrq9POR7b7KeupdrsSkmTorzuhvzaT6/186aWXsHnzZpxzzjlp9k477TTbetKPd8JAOOLsv//++Otf/4rPPvsM//jHPzBv3jx8/vnn+PrXv4633norbd299tqr2/Z77703du7ciU8++QRAZ7fVZZddhqFDh6K8vBz9+vVD//790djYiK1bt1rbvffee67de2vWrMGbb75pBWjyb++99wbgPEDkP//5j7HOpaWl2GOPPbqtu9dee6GoKP1yHT16dJotybBhw9K+S+ejdt+py9X82TfffBMnnHACamtrUVNTg/79+1uD1NTjY4detnQ0eo6uHcIm53by5Ml44okn8Oijj+Kaa65Br1698Nlnn6U1SmvWrMHWrVsxYMCAbudk+/btaecj2/2U9ZSNLCGE/lqum3R/7aWsoPdTHtc999wzzV5JSYntHMr0450wRzgmlJWVYf/998f++++PvffeG2eeeSbuvfdezJ8/35edCy64AIsXL8aFF16I+vp61NbWIpVK4ZRTTkFHR4cvWx0dHRg/fjyuvfZa4+/6DZ4viouLfS2XzqCxsRGHHnooampqsGDBAowcORIVFRV4+eWXcemll3o6Pm5l2NG3b18Anc7PlMvVr18/a2DGtGnTMGrUKHzta1/Db37zG8ydOxdA5/kYMGAAli5daiyjf//+ge2ndNL9+vVzXZeQpEF/7Z1C9Nd+ysrHftpBP94JA+EYst9++wHonE9WZc2aNd3W/de//oWqqiorCPrLX/6CWbNm4de//rW1TlNTExobG9O2GzlypOtbZ0aOHIl//vOfmDJliu8nyuHDh1t1Vqfgam1txbp16zBx4sS0dV977TV0dHSkqQyyS0jaypann34amzdvxl//+lcccsgh1vJ169YFYt+JUaNGWWWNHz/edf0ZM2bg0EMPxS9/+Uucd9556NGjB0aOHIknn3wSBx10UFpXmk4Q+ynXlSoPIcQM/XUnSfbX2eB1P+VxXbt2Lb761a9ay9va2vD+++9jwoQJ3WyvW7cORUVFVq9AUmFqRIRZvny58clUvt52n332SVu+cuXKtLyxDRs24MEHH8SRRx5pPXUWFxd3s3njjTd2m4Zl5syZ+Oc//4n777+/W/ly+5NOOgkffvghfv/733dbZ9euXdixY4ftvu23337o378/brnlFrS0tFjLlyxZ0s3JH3300WhoaMDdd99tLWtra8ONN96I6upqHHroobbl+EEeI/X4tLS04KabbgrEvhOTJk1CWVkZXnrpJc/bXHrppdi8ebN1/E866SS0t7fjyiuv7LZuW1ubdVyD2M/Vq1ejtrYWY8eO9bwNIYUM/XUn9NfB4nU/99tvP/Tt2xe///3v0dbWZi1funSpbarH6tWrMXbs2G553kmDinCEueCCC7Bz506ccMIJGDVqFFpaWvD888/j7rvvxu67744zzzwzbf1x48Zh2rRpadPxAMAVV1xhrfO1r30Nf/zjH1FbW4sxY8Zg5cqVePLJJ62uHskPf/hD/OUvf8E3vvENnHXWWZg0aRK2bNmC//u//8Mtt9yCiRMn4tvf/jbuuecenH/++Vi+fDkOOuggtLe345133sE999yDxx57zFJDdEpLS/Hzn/8c5513Hg4//HCcfPLJWLduHRYvXtwt5+zcc8/F7373O5xxxhlYvXo1dt99d/zlL3/Bc889h+uvvx49e/YM4nDjv/7rv9C7d2/MmjUL3//+95FKpfDHP/4xL3PlVlRU4Mgjj8STTz7peXLz6dOnY9y4cbj22msxe/ZsHHrooTjvvPOwcOFCvPrqqzjyyCNRWlqKNWvW4N5778VvfvMbfP3rXw9kP5944gkcc8wxic8tI0RCf90J/XWweN3PsrIyXH755bjgggtw+OGH46STTsL777+PJUuWYOTIkd18dWtrK5555hl873vfy2n9Y0EeZ6ggPvnb3/4mzjrrLDFq1ChRXV0tysrKxJ577ikuuOACsXHjxrR18cVUWn/605/EXnvtJcrLy8WXvvQlsXz58rT1PvvsM3HmmWeKfv36ierqajFt2jTxzjvviOHDh4tZs2alrbt582YxZ84csdtuu4mysjIxZMgQMWvWLPHpp59a67S0tIj/+Z//EWPHjhXl5eWid+/eYtKkSeKKK64QW7dudd3Hm266SYwYMUKUl5eL/fbbT6xYsUIceuih3aZ62bhxo1XvsrIyMX78eLF48eK0deQ0Nb/61a/Sli9fvlwAEPfee2/a8sWLFwsA4sUXX7SWPffcc+LAAw8UlZWVYvDgwdYUSADSjqXddDx62UJ0npv58+e7Hou//vWvIpVKifXr16ct16fjUZFT46jH4tZbbxWTJk0SlZWVomfPnmL8+PHikksuER999FHW+ymEEG+//bYAIJ588knXfSIkKdBfd0F/3X36tHzspxBC3HDDDWL48OGivLxcHHDAAeK5554TkyZNEkcddVTaen/7298EALFmzRrXfS10UkLw1VCFQCqVwuzZs/Hb3/427KqQDGlvb8eYMWNw0kknGdMbosKFF16IFStWYPXq1VSECckA+uv4Exd/3dHRgf79++PEE09MS4s5/vjjkUqljOk0SYM5woREhOLiYixYsACLFi3C9u3bw66Okc2bN+O2227Dz3/+cwbBhJDEEkV/3dTU1C1l4g9/+AO2bNmS9orlt99+Gw8//HCkA/h8QkW4QKDCQAgh8YD+muSC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8MOxYsUKHHrooYHWQWf06NEYN24clixZwkCYhAp9MnEi3z65pKQE3/rWt6zv3/ve9zB58mTcfffduPbaazF48OBA65ILevXqhSOPPBJLliyJVSDMuzpknn76aaRSKdx111346U9/it122w1VVVXYtm0bAGDVqlU46qijUFtbi6qqKhx66KHG3KJnn30W+++/PyoqKjBy5Ej87ne/65aH9f777yOVSmHJkiXdtk+lUrj88svTln344Yc466yzMHDgQJSXl2Ps2LH43//9X2P977nnHvziF7/AkCFDUFFRgSlTpmDt2rXdylm1ahWOPvpo9O7dGz169MCECRPwm9/8BgCwePFipFIpvPLKK922++Uvf4ni4mJ8+OGHjsfTdBxMtLW14corr8TIkSNRXl6O3XffHT/+8Y+7dUPtvvvu+NrXvoann34a++23HyorKzF+/Hg8/fTTAIC//vWvGD9+PCoqKjBp0qRudX/ttddwxhlnYI899kBFRQXq6upw1llnYfPmzWnrmfLRZNnPPvssDjjgAFRUVGCPPfbAH/7wB8djIHnggQcwefJk25yypUuX4ogjjsBXv/pVjB49GkuXLrW1tXPnTpx33nno27cvampqcPrpp+Ozzz5LW+ell17CtGnT0K9fP1RWVmLEiBFGZ3jEEUfgoYceghDC034Qkk/ok+mTgXB8skpRUREOO+wwAOiWp/zhhx/i+OOPR3V1Nfr374+LL74Y7e3taetcc801+K//+i/07dsXlZWVmDRpEv7yl790K+eJJ57AV77yFfTq1QvV1dXYZ5998OMf/zhtnebmZsyfPx977rknysvLMXToUFxyySXGtI0jjjgCzz77LLZs2eK6j5FBkJyyePFiAUA8+eST4pNPPkn7E0KI5cuXCwBizJgxYt999xXXXnutWLhwodixY4dYtmyZKCsrE/X19eLXv/61uO6668SECRNEWVmZWLVqlVXGa6+9JiorK8WwYcPEwoULxZVXXikGDhwoJkyYINRTvG7dOgFALF68uFs9AYj58+db3xsaGsSQIUPE0KFDxYIFC8TNN98sjj32WAFAXHfdddZ6sv5f+tKXxKRJk8R1110nLr/8clFVVSUOOOCAtDIef/xxUVZWJoYPHy7mz58vbr75ZvH9739fTJ06VQghxLZt20RlZaX4wQ9+0K1+Y8aMEYcffrjjsfZ6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vOzzSSedhOeffx4//OEPse+++6K6uhodHR046qijbJ/A/ZZhom/fvgBgdM5/+tOfAAAXXXQRLrroom6/33fffTjzzDNd66aSSqXwl7/8BS+88AIeeughPPbYYzjrrLPw61//Gi+88ELa4BBZp379+vkqg5B8Qp/cZYc+2V8ZJpx8srQ7derUjMtX+fvf/45jjz0WhxxyCG666SYMGjQIpaWlWLx4Me68805rvcrKSqxYsQLLly/H//t//w+PPvoo7r77bhx++OF4/PHHUVxcjI6ODowfPx7XXnutsSz94SOO/p2BcEQZOXIkAKCmpsbx5ujfvz8qKysttULl3XffTfsuFYHGxsa05frTdv/+/dGzZ0+0t7d7ujG9IPfnjTfecLV5+umn49e//jUeeugh/O1vf0P//v1duxT9HIfhw4ejo6MDa9aswejRo63lGzduRGNjI4YPH+51txz57LPPsGzZMlxxxRW47LLLrOWmOgbNsGHDUFlZiXXr1qUtF0LgzjvvxFe/+lV873vf67bdlVdeiaVLl3YLhNesWYOvfvWr1vft27fj448/xtFHH5223oEHHogDDzwQv/jFL3DnnXfitNNOw1133YXvfOc71jqyTuqxJyTq0CfTJ2eDnU/OBffddx8qKirw2GOPpfXILV68uNu6RUVFmDJlCqZMmYJrr70Wv/zlL/GTn/wEy5cvx9SpUzFy5Ej885//xJQpUzw9qMTRvzNHOKJMmjQJI0eOxDXXXIPt27d3+/2TTz4B0Pl0OG3aNDzwwANYv3699fvbb7+Nxx57LG2bmpoa9OvXDytWrEhbrr8Ssbi4GDNnzsR9992HN954w7ZsP3z5y1/GiBEjcP3113dz+vqT9IQJEzBhwgTcdtttuO+++3DKKaegpMT5mc3PcZDB2/XXX5+2XD7xzpgxw8+uOdYJ6L5/erm5oLS0FPvtt1+3VyM/99xzeP/993HmmWfi61//ere/k08+GcuXL8dHH32Utt2tt96alvt78803o62tDdOnTwfQ2cDo+ylVM10hW716NWprazF27NigdpeQnEOfTJ+cDXY+ORcUFxcjlUql9Sy8//77eOCBB9LWM01xpvvtk046CR9++CF+//vfd1t3165d2LFjR9qy1atXI5VKpeVwRx0qwhGlqKgIt912G6ZPn46xY8fizDPPxG677YYPP/wQy5cvR01NjZVPdMUVV+DRRx/FwQcfjO9973toa2vDjTfeiLFjx+K1115Ls/ud73wHV111Fb7zne9gv/32w4oVK/Cvf/2rW/lXXXUVli9fjsmTJ+Occ87BmDFjsGXLFrz88st48sknfc8RWFRUhJtvvhnHHHMM9t13X5x55pkYNGgQ3nnnHbz55pvdHOPpp5+Oiy++GAA8d8F5PQ4TJ07ErFmzcOutt6KxsRGHHnoo/vGPf+COO+7A8ccfn6Z8ZkNNTQ0OOeQQXH311WhtbcVuu+2Gxx9/PC+KAAAcd9xx+MlPfpKW87t06VIUFxfbNizHHnssfvKTn+Cuu+5KG7jS0tKCKVOm4KSTTsK7776Lm266CV/5yldw7LHHAgDuuOMO3HTTTTjhhBMwcuRIfP755/j973+PmpqabqrxE088gWOOOSZWOWSE0CfTJ2eLySfnghkzZuDaa6/FUUcdhW9+85vYtGkTFi1ahD333DPtuC9YsAArVqzAjBkzMHz4cGzatAk33XQThgwZgq985SsAOt+wd8899+D888/H8uXLcdBBB6G9vR3vvPMO7rnnHjz22GNpufZPPPEEDjroICsVJBbkd5KK5CGnYLGbVkpOdXPvvfcaf3/llVfEiSeeKPr27SvKy8vF8OHDxUknnSSWLVuWtt4zzzwjJk2aJMrKysQee+whbrnlFjF//vxuczXu3LlTnH322aK2tlb07NlTnHTSSWLTpk3dpuoRonNu2NmzZ4uhQ4eK0tJSUVdXJ6ZMmSJuvfVW1/rbTQv07LPPiiOOOEL07NlT9OjRQ0yYMME4nczHH38siouLxd577208LnZ4PQ6tra3iiiuuECNGjBClpaVi6NChYt68eaKpqSltveHDh4sZM2Z0KweAmD17tnGff/WrX1nLPvjgA3HCCSeIXr16idraWvGNb3zDml5GPd52U/WYyj700EPFoYce6nosNm7cKEpKSqy5gltaWkTfvn3FwQcf7LjdiBEjxJe+9KW0ej3zzDPi3HPPFb179xbV1dXitNNOE5s3b7a2efnll8Wpp54qhg0bJsrLy8WAAQPE1772NfHSSy+l2X777bcFvpjDlZAwoE9enLacPjk8nyyxm0dYx2490/G8/fbbxV577SXKy8vFqFGjxOLFi7utt2zZMnHccceJwYMHi7KyMjF48GBx6qmnin/9619ptlpaWsT//M//iLFjx4ry8nLRu3dvMWnSJHHFFVeIrVu3Wus1NjaKsrIycdttt7nuS5RICcHXOxUql19+Oa644opYvsHr008/xaBBg3DZZZfhZz/7WdjViS1nn302/vWvf+Hvf/972FUBAFx44YVYsWKF1X1GSJKgTyZR88lBcv311+Pqq6/Ge++9122waZRhjjCJJEuWLEF7ezu+/e1vh12VWDN//ny8+OKLxlfA5pvNmzfjtttuw89//nMGwYTEDPrkYIiSTw6S1tZWXHvttfjpT38aqyAYYI4wiRhPPfUU3nrrLfziF7/A8ccfj9133z3sKsWaYcOGoampKexqAOicPsg0yIgQEl3ok4MlSj45SEpLS9MGRcYJBsIkUixYsADPP/88DjroINx4441hV4cQQhINfTIpdJgjTAghhBBCEkmoOcKLFi3C7rvvjoqKCkyePBn/+Mc/wqwOIYQQQghJEKEFwnfffTfmzp2L+fPn4+WXX8bEiRMxbdq0bu+tJoQQQgghJBeElhoxefJk7L///vjtb38LoPNd6kOHDsUFF1yAH/3oR2FUiRBCCCGEJIhQBsu1tLRg9erVmDdvnrWsqKgIU6dOxcqVK7ut39zcnPaa1o6ODmzZsgV9+/blNEyEkJwihMDnn3+OwYMHo6iIM076gb6bEBIWXn13KIHwp59+ivb2dgwcODBt+cCBA/HOO+90W3/hwoW44oor8lU9QgjpxoYNGzBkyJCwqxEr6LsJIWHj5rtDSY346KOPsNtuu+H5559HfX29tfySSy7BM888g1WrVqWtr6sKW7duxbBhwzCm1yQUpzgDHCEkd7SLNrzVuBqNjY2ora0Nuzqxws53v/D0KlRX99DWTgHIpDkybUdbtEVbSbe1fft2HHjYga6+O5Qosl+/figuLsbGjRvTlm/cuBF1dXXd1i8vL0d5eXm35cWpEgbChJC8wK58/9j57urqHuhZ3VNZIhs39RgL5Tf9u4B9g0hbtEVbtNW1jpvvDiXhraysDJMmTcKyZcusZR0dHVi2bFmaQkwIISQJqI2f/NN/N21DW7RFW7Tltp0zocmpc+fOxaxZs7DffvvhgAMOwPXXX48dO3bgzDPPDKtKhBBC8oqq/NgpO3aNm52aRFu0RVu05Z3QAuGTTz4Zn3zyCS677DI0NDRg3333xaOPPtptAB0hhJBCxK77U7isI1UhdV3aoi3aoi07W86EmmA7Z84czJkzJ8wqEEIICQ2nhkrA3ADqDSVt0RZt0ZadLXc4KSYhhJAQULs0TZ8Bc2Mm10lpy2iLtmiLtnRb7jAQJoQQEgJ6d6baAHpVhFLKf9qiLdqiLf8wECaEEBICuvKjL7NrCNV1VYWItmiLtmjLPwyECSGEhIRJ0XFSfNT1aIu2aIu2/NrqDgNhQgghIWFSeuR/kxIktzE1fLRFW7RFW262usPXshFCCAkZOxVIKL+rjZ++jLZoi7Zoy2TLHQbChBBC8ozepenW6Jl+1xs/2qIt2qIt/zAQJoQQkmcE3LsuvTZqtEVbtEVbmcMcYUIIISGgqj5OuP1OW7RFW7TlxZYZBsKEEEJCxE3RkaoQkN7YpdC98aMt2qIt2vIXFDMQJoQQkmf0hs7us8SUIyhzB2mLtmiLtuxsucNAmBBCSJ6RDR3Q2aDpjaCK3uDpjSFt0RZt0ZaTLWcYCBNCCAkRJ/VGb/zk+naNHG3RFm3RlputdBgIE0IIyTNql6fTf7cuTr37lLZoi7Zoyx8MhAkhhOQZtcvTTrlRGze98dO7SGmLtmiLtpxs2cNAmBBCSMjIBkuqO6YGT1+PtmiLtmgrU1tdMBAmhBASMrJhM6k/frs+aYu2aIu2vMNAmBBCSJ7xotTIRs7U9WlaRlu0RVu0ZWfLHr5imRBCSAQxKTz+ujxpi7Zoi7bcoCJMCCEkz+jqjZtyk0J6o6Z3j9IWbdEWbZlsucNAmBBCSJ5xUmpMv+kNmtr1SVu0RVu05WTLGaZGEEIICQlTbp+uBpny/UxKD23RFm3Rlput7lARJoQQkmdUtcdN9VG7SvXfaIu2aIu2nGy5w0CYEEJInvHSZSkbN7t1dSWItmiLtmjLPwyECSGE5Bm1W1Nt9NQGTe/6VFFVItqiLdqiLSdbzjAQJoQQEgJ6w2Vq0NRGMQX7xo+2aIu2aMvOljMMhAkhhOSZlPbf7rNdI6g3fLRFW7RFW3a2nOGsEYQQQvKMm1JjUnv0rlEvdmiLtmiLtpyhIkwIISQk9IZKV4BMjZna0KUMy2mLtmiLtrwFwQADYUIIIaGQgnuDp6pDepeprgbRFm3RFm3pttxhIEwIISQE9MbLu4KT3uDRFm3RFm3Z2XKHgTAhhJAI4E298da40RZt0RZteYOD5QghhEQIVfWRjZ6pUfPaaNIWbdFWcm25w0CYEEJIntFVHb1Bk42c0L6r65h+oy3aoi3akp+9qcdMjSCEEJJnBNIbM7vAWP7u1BjSFm3RFm3Z2XKHijAhhJCQcGusZONmauScGkXaoi3aoi1vwXDgivDll1+OVCqV9jdq1Cjr96amJsyePRt9+/ZFdXU1Zs6ciY0bNwZdDUIIIZHGTu3RVSGnZW7KEW3RFm0l25Y7OUmNGDt2LD7++GPr79lnn7V+u+iii/DQQw/h3nvvxTPPPIOPPvoIJ554Yi6qQQghJFaoDZlQvqu/mxpD2qIt2qItO1vO5CQ1oqSkBHV1dd2Wb926FbfffjvuvPNOHH744QCAxYsXY/To0XjhhRdw4IEH5qI6hBBCIolQ/puUHZ0U0htB3QZt0RZt0ZZdgGwmJ4rwmjVrMHjwYOyxxx447bTTsH79egDA6tWr0draiqlTp1rrjho1CsOGDcPKlStzURVCCCGxQG/kYPgutM92DR1t0RZt0ZY3AleEJ0+ejCVLlmCfffbBxx9/jCuuuAIHH3ww3njjDTQ0NKCsrAy9evVK22bgwIFoaGiwtdnc3Izm5mbr+7Zt24KuNiGEkIDx77vVRk4YvgPpyhFt0RZt0ZadLW8EHghPnz7d+jxhwgRMnjwZw4cPxz333IPKysqMbC5cuBBXXHFFUFUkhBCSBzLz3WpD6NRQemk0aYu2aCvZttzJ+TzCvXr1wt577421a9eirq4OLS0taGxsTFtn48aNxpxiybx587B161brb8OGDTmuNSGEkGzx7rtTMCs4bsqRaRvaoi3aoi3YrNudnAfC27dvx3vvvYdBgwZh0qRJKC0txbJly6zf3333Xaxfvx719fW2NsrLy1FTU5P2RwghJNrY+25Tgya031KG3/TvwrAubdEWbdGW6buZwFMjLr74YhxzzDEYPnw4PvroI8yfPx/FxcU49dRTUVtbi7PPPhtz585Fnz59UFNTgwsuuAD19fWcMYIQQhKDVHDU76bPujrk1B1KW7RFW7Rl2s6ZwAPhDz74AKeeeio2b96M/v374ytf+QpeeOEF9O/fHwBw3XXXoaioCDNnzkRzczOmTZuGm266KehqEEIIiTSy8TItl+gNn7qNqSGkLdqiLdqy28ZMSgjhL3SOANu2bUNtbS3G956M4hTfEk0IyR3tog2vf7YKW7duZVpWlkjf/cZLb6BndU+HNb0oPF7VH9qiLdpKoq3Pt3+OcfuNc/XdOc8RJoQQQrqTsvkMpKs6do2h+htt0RZt0VZmMBAmhBCSZ9SuS71x09Ud/Xd9GW3RFm3RlpstexgIE0IICYEU0tUctWFTGzCnhjClfaYt2qIt2vIHE2wJIYTkGb0r0075kcv1xs2pG5S2aIu2aMvNVhdUhAkhhOQZtwZLNmrqf9qiLdqirUxt2cNAmBBCSEjoqo7amJkaPdqiLdqirWxsdYeBMCGEkDxjaqzsGjC3ho22aIu2aCszNRhgIEwIISQU7PL69HVoi7Zoi7aCsGWGgTAhhJAQMHVt6g2ervLoDV5KW4+2aIu2aMtfYMxAmBBCSEh4UXlSyp9T9ydt0RZt0Zabre5w+jRCCCEh4SUHUP2uN3J2n2mLtmiLtrxBRZgQQkiekcoNkK726N2duhJk6gKlLdqiLdpysuUMFWFCCCF5xovy46TqyIZP2HynLdqiLdrSA2QzVIQJIYREAF39MTVu3ho22qIt2qItr1ARJoQQEgFUBcitcXNbl7Zoi7Zoy04pToeKMCGEkBDQuz7lZ7vGSxh+E9p/2qIt2qItbwGwhIEwIYSQPKN3XcrvXhswtWuUtmiLtmjLyZYzDIQJIYTkGV3lMak+bkhViLZoi7ZoK3OYI0wIISQETN2bdg2fvr6+HW3RFm3RlpstMwyECSGEhIBdI2Vq+Ezre2nwaIu2aCvZttxhagQhhJAQ0XMB/TdktEVbtEVbmcJAmBBCSAjIhk3v5tTz//QG0K47lLZoi7ZoS7flDlMjCCGE5Bm1YdPRG0F9mZccQtqiLdqiLW9QESaEEJJnnHL7VGVHfPFnUn9oi7Zoi7a82rKHijAhhJCQMSk5eiOnY6cW0RZt0RZteYeKMCGEkAihKz1Ad2XHa4NHW7RFW7TlDANhQgghIZCy+awqO7Kxc2vQaIu2aIu2qAgTQgiJDXaNlt7VKZDeAKrb64oQbdEWbdGWPxgIE0IICQFd3ZHLTA2fusxuO9qiLdqiLZMtZxgIE0IICQG1UdO7P90asZT2n7Zoi7Zoy86WM5w1ghBCSJ6RjZjs0pToKo+d8qP+pjaYtEVbtEVb/qAiTAghJARMXZ/Qlpm6OvVGkbZoi7Zoy4stMwyECSGE5Bm1cVOVH9MyUxep+p22aIu2aMvOljsMhAkhhOQZk5Ijl6uNoN7wmWzQFm3RFm3Z2XKHOcKEEELyjKnLMmVYLgyf9W5Q2qIt2qItJ1vOUBEmhBASEn7UG31dge4NJ23RFm3Rlj8YCBNCCAkJO/XGravUtB5t0RZt0ZZpuTO+A+EVK1bgmGOOweDBg5FKpfDAAw+k/S6EwGWXXYZBgwahsrISU6dOxZo1a9LW2bJlC0477TTU1NSgV69eOPvss7F9+3a/VSGEEBJLUnDOAxQ2vwltO/mdtmiLtmjLZMsd34Hwjh07MHHiRCxatMj4+9VXX40bbrgBt9xyC1atWoUePXpg2rRpaGpqstY57bTT8Oabb+KJJ57Aww8/jBUrVuDcc8/1WxVCCCGxxamxSmnruKlDtEVbtEVbJlvu+B4sN336dEyfPt34mxAC119/PX7605/iuOOOAwD84Q9/wMCBA/HAAw/glFNOwdtvv41HH30UL774Ivbbbz8AwI033oijjz4a11xzDQYPHuy3SoQQQmKFbLhURcek9OifVfRGkLZoi7Zoyz+B5givW7cODQ0NmDp1qrWstrYWkydPxsqVKwEAK1euRK9evawgGACmTp2KoqIirFq1ymi3ubkZ27ZtS/sjhBASbbz5br1x039z6uLUt6Mt2qIt2vJHoIFwQ0MDAGDgwIFpywcOHGj91tDQgAEDBqT9XlJSgj59+ljr6CxcuBC1tbXW39ChQ4OsNiGEkByQue9WFSG7370qQLRFW7SVXFvuxGLWiHnz5mHr1q3W34YNG8KuEiGEEBfsfbds7PRuT7eGDYbfaYu2aIu23GzZE+gLNerq6gAAGzduxKBBg6zlGzduxL777muts2nTprTt2trasGXLFmt7nfLycpSXlwdZVUIIITnG3nfLxk3ipYvUpBSpDSVt0RZt0ZYfRbmTQBXhESNGoK6uDsuWLbOWbdu2DatWrUJ9fT0AoL6+Ho2NjVi9erW1zlNPPYWOjg5Mnjw5yOoQQgiJJLKxkg2W+tlpfR1VLaIt2qIt2vIXBAMZKMLbt2/H2rVrre/r1q3Dq6++ij59+mDYsGG48MIL8fOf/xx77bUXRowYgZ/97GcYPHgwjj/+eADA6NGjcdRRR+Gcc87BLbfcgtbWVsyZMwennHIKZ4wghJDEoneD2jVqareonUJEW7RFW7TlpCB34TsQfumll/DVr37V+j537lwAwKxZs7BkyRJccskl2LFjB84991w0NjbiK1/5Ch599FFUVFRY2yxduhRz5szBlClTUFRUhJkzZ+KGG27wWxVCCCGxxqlRE9o60JbTFm3RFm052bKzp1kXQnhbM0Js27YNtbW1GN97MopTgaY5E0JIGu2iDa9/tgpbt25FTU1N2NWJNdJ3v/HSm+hZXa396qbw6DitT1u0RVtJt/X59s8xbr9xrr47FrNGEEIIKTT0bktd4fGyTcpmOW3RFm3RljcopxJCCMkzstsypXxXsVN77LpB5TLaoi3aoi3VljsMhAkhhISEl25SJ9SGkLZoi7Zoyz9MjSCEEBIB1K5QO0UnBW8NJG3RFm3RlrcAmoowIYSQPOPUUDl1a+q/Ce0zbdEWbdGW02/doSJMCCEkJNSGSm349MbQtFxvHGmLtmiLtvTl7lARJoQQEgJOKo/62bSMtmiLtmjLry0zVIQJIYTkGbWh0rsvU0hXeJy6N+W6tEVbtEVbmcFAmBBCSAjYdV/qCo/aFapvL5TPtEVbtEVb/mEgTAghJATUhktXdewaP5MqRFu0RVu05WTLGeYIE0IICQGp+qjqj8TLcr3blLZoi7ZoS13uDQbChBBC8ozaeKmNltqgOS2nLdqiLdryY8sepkYQQgjJM7KRUhswOxVHLrdTeGiLtmiLttxs2UNFmBBCSETQVRxhWG7qCqUt2qIt2tJteYOKMCGEkJBQ1R1dzTF9Thm2oS3aoi3acrLlDANhQgghISAbKVXNMalBdg2j+p+2aIu2aMvOljMMhAkhhISAXTemVHyE8l+urzdseiNJW7RFW7TlLQCWMBAmhBCSZ/RuTgFzwweHZXI5bdEWbdGWky1nGAgTQgjJM6qK46T4CG0ZbdEWbdFWJrbsYSBMCCEkZPTGTm30TCpPSvtPW7RFW7RlZ8sZBsKEEEJCwK6bU19masz0hpC2aIu2aMvOljMMhAkhhEQIvfHSvzspRrRFW7RFW15tdMJAmBBCSEhk0p1pp/LQFm3RFm252eoO3yxHCCH/v737j4+qvvM9/p6EZCSQSQyQBBQQUEHkh4oUcm0tXSg/pC1U3IdWVrHLwkqDW8FSLl6LSvcWi926tbV6u9sWuyvaeivlAeuPIkjQGlGolF8lVyhtUAhBMD8Ayc/v/cOd6Zkv58yPAHNmyOv5eMxjZs75zptvMD2fbz/fk4AUC7i8NnIvbs5j4THGGm9vj5JFFllkJYaOMAAgxYz12rgct8c6n+2iRxZZZJEVK8sbC2EAgA/s+wHtjo9bQQxY4+1uEVlkkUWWfTw2bo0AAPjA7urY3R/JvcjF6gaRRRZZZIWf3fLOREcYAOAjZ7GyuzjOYpZIh4csssgiKzkshAEAPgi4vPbq4BhFF7vwZ+ztUrLIIousxDrBYSyEAQA+sLc4vYqfrHNuGWSRRRZZXlmxsRAGAPjAq/g5i5yzA2R3eWJtlZJFFllkJYaFMADAB3axc3aA3LpBzrFkkUUWWclmuWMhDADwkbO4xRoTFqtbRBZZZJFlZ8XGQhgA4APndqbzvXOrU9YY+/P2Z8kiiyyyksNCGADgk4D1LEVveZJFFllkncusM/EPagAAfGAXMbvjI+u92/hw54gsssgiyy0rvqQ7wps3b9YXv/hF9enTR4FAQL/5zW+izt91110KBAJRj8mTJ0eNOX78uGbOnKlQKKTCwkLNnj1bJ06cSHryAIBM5ezyOIuXV6fHLnzhsWSRRRZZsbJiS3ohfPLkSY0cOVJPPPGE55jJkyfr8OHDkcezzz4bdX7mzJnavXu31q9fr3Xr1mnz5s2aO3du0pMHAGQq43iOVbyM42FvkdpdILLIIossOyO2pG+NmDJliqZMmRJzTDAYVGlpqeu5P/7xj3r55Zf1zjvv6Prrr5ck/fCHP9RNN92k733ve+rTp0+yUwIAZCy70xPmdcw53h5DFllkkWVnxXZeflhu06ZNKi4u1uDBgzVv3jwdO3Yscq6yslKFhYWRRbAkTZgwQVlZWdqyZYtrXlNTkxoaGqIeAID0lti126tYBazXdoco1nYqWWSRRVZizvlCePLkyfrFL36hDRs26Lvf/a4qKio0ZcoUtbW1SZJqampUXFwc9ZkuXbqoqKhINTU1rpnLly9XQUFB5NG3b99zPW0AwDkW+9rtLFrOY2F2IbO3Rp3nySKLLLLcsuI75wvh2267TV/60pc0fPhwTZ8+XevWrdM777yjTZs2dThzyZIlqq+vjzwOHjx47iYMADgvvK/dzm3LWAXQPu7W5SGLLLLIipfl7bz/+rSBAweqZ8+e2rdvn8aPH6/S0lLV1tZGjWltbdXx48c97ysOBoMKBoPne6oAgHMo9rU7XORibWfahdB4jCWLLLLI8hof23n/BzXef/99HTt2TL1795YklZWVqa6uTtu2bYuM2bhxo9rb2zVmzJjzPR0AgO+Mors3zvcBlzFyGe82jiyyyCLL7bi3pDvCJ06c0L59+yLvDxw4oO3bt6uoqEhFRUV6+OGHNWPGDJWWlmr//v365je/qcsvv1yTJk2SJF111VWaPHmy5syZo6eeekotLS2aP3++brvtNn5jBAB0Gl5bncYaY1xeu40jiyyyyLKz4ku6I7x161Zde+21uvbaayVJCxcu1LXXXqulS5cqOztbO3bs0Je+9CVdeeWVmj17tkaNGqXXX389anvsmWee0ZAhQzR+/HjddNNN+vSnP62f/OQnyU4FAJDREu3auBU3e9uTLLLIIiu5RbDUgY7wuHHjZIz3H/TKK6/EzSgqKtKqVauS/aMBABcMI+/CFT7uHON1fyBZZJFFViJZ7s77PcIAALjzKm5uY+yxxhpLFllkkZX4AjiMhTAAII2EOzxhdsGzx5JFFllkxcqKjYUwACDF7KIVcDkWZlxeO8eSRRZZZMXL8sZCGACQYnahsrs/yXR3yCKLLLI6joUwAMAHAXl3cZz3CAas12SRRRZZHclyx0IYAJBidhGTzixexvFsXI6RRRZZZCWa5Y2FMAAgxdwKlFfxitXdsbtFZJFFFlnJYSEMAEgxt6JlHwu/Nx7nw+fIIosssmJlxcZCGACQYl6dnnARc3aDAvIubrG6RmSRRRZZ8bEQBgCkAWdRswug171+XsfJIossshLDQhgAkGLh7o2zcCVTxALWa7LIIousWFneWAgDAHzgtaVpFy/jcYwsssgiK5ksdyyEAQA+CBctY713G2fijCOLLLLIipflrkvCIwEfZOUWqEvXXsrKyZckmbYmtZ3+UG3N9TJtTT7PDkDHxOr6GMdzrLHO9wGX82T5mfWXZx6WaW3Th7vrVDK6VD/ZNVmLll6tUHa2pOwL4mskK5OyvLEQRtrKyi1Q8OIhuujSMnXpN0KBop5SdrZ0+rRM3XG1H39frR9Vq+Wj/WppOKDWj2tZHAMZwy5qcnl2Fjz7c2Sla9ahFx/TyYP1Mu1SQd+u+rfqL2r6PSV6dNluPfytK/Xgsir907T9evzlKzX70hd02ayHM+5rJCsTs9yxEEbaysoOKlgyUjmjPq/3P9emuqu36JKCPyk30KQTrfmqbyrS6dP9FGgYpe7HStT7vVLlrt+i+rd+oJYTB/2ePgBPAcez3dmxi5m9NerMMDGeyfIrq2Tq19V89CGdeL9R/xm4U6O+VKA1/35YDy27Rg8u+4O+MbtJ//LMYM0d/KL+/chtuvWR+7RuRLk+99oPNPrRxzPiayQrU7LiYyGMtJWVG1KX4it17PpclX7mB/q3pv+r67blKvfQaQVOt50x/sTw7voft8xWyVfn6tjmb/kwYwCJszs84fd2wXOet8faC2qy0iHr8K+/q+N767V28NfUZ3BQv9/QoPIFl+vhx/bof/1Tkb7zs0bNG71JT703Tbc3/Vy/vmKeJr/+uLbOWKjRGfI1kpUpWfGxEEbaysrJV1ZhsY4M+IsW5K7T2F+36eBP39WfDzar/pTR6VapuU1qbv/kuWxgth56p00/KV7l99QBJM3Z6XHr8HidJyvdsvrMWKxfaL9ys6TaPzVr0vQiPfXMQS37+mA99NP9+vqEP+lH2z6vu7r+p54t/UdN3f4jvfu3C3X1z74njX08I75GsjIlKz4Wwkhvwa5qyT+sS0+dVssfPtSf/tKs7UeMGlsDamqPHjrsZLty2o2Ule3PXAEkwS5Szk6P23n7c7EKHll+Zv30rT+r+ViLupbk6IqRF2nTmyf0jbsG6n//+s/65jSjH7wxVHOK12pV1l26edeTqpy2QMOf+xc1LL4/Y75GsjIlKz5+fRrSX5cWXdQqtX/cqtMtRk3tOmMRHKX9zNsmAKQTr+IWcDyc5+z3zmey0i3r78f2U8HAoPIKs3XocKumfe5i/Z8N7+v+m/vqJzuDmnv5Hq0pmK6//fA/tH3yP+n6//pX5f7Ppcr7/vKM+RrJypSs+FgII20FsnOl7MS+RbMD0kXBgA7ndld7w/HzPDMA51a4uBnHwz7v9hmy0jHrhT0f6HRju7KzpKuvCOqNvY2a//n++tm2Gs0d3qTf6hpN//gl7f4fczRq0xPK+fq31P7YtzX8X76XMV8jWZmSFR8LYWS07IDUM9doTLHRdf94pR6qekQn//Cs39MCkBBn18a5Hep23O2zXh0gsvzMmnZVH/Xq00X5+VmqrW/VlJFFWvPHQ/rqqBK9fjKkSbl79cHIWzTiD/+pbv/wLQV+ulzXfPtftfO+b2TM10hWpmTFxz3CyDhdAlIwSyrMMRrSQ7p+TEgtD12lS7ov05C/36e6I1v8niKAuMKdHZuJMybcFXKOJSudsrbWHFVLi1Goe7Yu6xnU3qMn9OWhl+jdox/qsxe36sPcseq/67+U/+X7VLvqO7ri3mV694F7Nfaxx8/IStevkaxMyoqNhTAyykU5AZV2NbqkQBpxbb7y7h2m+264WtufeVJXPPGE6t77ld9TBJCwWIXKbevT+Rn7HFnpkjWiuEgffHREoa5Zampt1zW9C/TnhgZd16NQJ02hCg7vVP7oW3Xk9Z+pdNocVX3vAV37z/963udFVmfMio+FMNJb2yc/FdeSLeWUdtPlA4Ia2SNXoTsH6z9uukgP7PqRhs3rpvYN89T40R6fJwsgceGOjVvnJ+ByzPk5+xxZ6ZRV13RaXbKlvNxslXYPqqWtXZeFQmqXFDx1TFnFV+ij915T3qDr9MGzP9JV3/yeZ1a6fo1kZUKW2wL5TCyEkbZMW7MkKdDUTQfzgrpmfIl6frZEv7uuWfOb7lGfH8/Ulb/+D32495dqb673ebYAkmMXL2fBc+sIeXV97EJJlt9ZF2V3UahrtvKD2eqWm6P83Bx1MVmSWtXa2qS2phM6VjpKXSt+qgH/8M8Z+TWSlUlZsbEQRnpr+ljdjvbRysumaMfYPdp0aqzqKm9X/+cb1Pj6N3T6w+0ybU1+zxJA0uIVuVjdHsn7c2T5nVWQG1SPvBwVXJSrLlkB5WflqjXQruaj+3XRxX11qLlVg/JCCnxhoZWXOV8jWZmSFR8LYaQt09ak9rpa9d01RgfaZ+uDU111ydau6vr6Wn1U9Wu1nDjo9xQBnBWvjk7AcT6gM8XaViXL76xDJ09IkvK6dFHPYJ5Omza1176nvF5DVNN0Upfk5Stg3BY453deZHXGrPhYCCNttbc0qrX2/ylnW5EG7M5TW81Ondy3QR8feYdbIYALglunx/mcaKEkK52y+nW9SPnBXHXN7qKm9lYFGg6pub5G2b2uUMlF3RQw/syLLLLcsBBG2jJtTWr+cK/aPq5T++njav6oSq0f13IrBHDBcStkRtGFzq34eRVNsvzMqmo8qfzcHOV3yVWgqUEnjv5JBYM+rYC6SObC+BrJypSs+FgII221Nder+aO90kd71dZczwIYuGA4tzSdz25jvM7bxY+sdMkaEDihYNd+2nDwkMb3LVHhoE9LgRzf50VWZ8yKj4Uw0pZpa1Lrx7V+TwPAOWcUf+sy0aJGVrplBUOXSDL6XL/e/z0kK4HMzPoaycq0LG/8E8sAAB84uz6xxDtPVvplfbK0yIpao6TDvMjq3FnuWAgDAHyUaKdQii52AZ1Z/MgiiyyyklsUsxAGAKSYXei8Xoe53SMYvneQLLLIIssrKz4WwgCAFAsXOumTgmYXQSe74NnFkCyyyCIrVlZsSS2Ely9frtGjRys/P1/FxcWaPn26qqqqosacPn1a5eXl6tGjh7p3764ZM2boyJEjUWOqq6s1depU5eXlqbi4WIsWLVJra2syUwEAXBBidW/s4hce71XkyCKLLLLiZUVLaiFcUVGh8vJyvfXWW1q/fr1aWlo0ceJEnTx5MjJmwYIFWrt2rZ5//nlVVFTo0KFDuvnmmyPn29raNHXqVDU3N+vNN9/U008/rZUrV2rp0qXJTAUAkLGcW56xnuNtcdrbp2SRRRZZyQkYYzqccPToURUXF6uiokI33nij6uvr1atXL61atUq33HKLJGnv3r266qqrVFlZqbFjx+qll17SF77wBR06dEglJSWSpKeeekqLFy/W0aNHlZubG/fPbWhoUEFBgYZfPEbZAX4DHIDzp820audHW1RfX69QKOT3dDJa+Nq9a+su5XfPd5wJF7Vw8XPb9vQ6ZyOLLLLIMmo80ahh1w+Le+0+q3uE6+vrJUlFRUWSpG3btqmlpUUTJkyIjBkyZIj69eunyspKSVJlZaWGDx8eWQRL0qRJk9TQ0KDdu3efzXQAABkpXNCchSzeOLLIIousjmb9VYfbqe3t7br33nt1ww03aNiwYZKkmpoa5ebmqrCwMGpsSUmJampqImOci+Dw+fA5N01NTWpq+uu/KtbQ0NDRaQMAUiTxa7fd3XF2fJLd+iSLLLLISlyHO8Ll5eXatWuXnnvuuY5GJGz58uUqKCiIPPr27Xve/0wAwNnxvnYn0qlxK27OrVH7GFlkkUWWV5a3Di2E58+fr3Xr1um1117TpZdeGjleWlqq5uZm1dXVRY0/cuSISktLI2Ps3yIRfh8eY1uyZInq6+sjj4MHD3Zk2gCAFDq7a7dbh8e55Rl+kEUWWWR5ZcWX1ELYGKP58+dr9erV2rhxowYMGBB1ftSoUcrJydGGDRsix6qqqlRdXa2ysjJJUllZmXbu3Kna2trImPXr1ysUCmno0KGuf24wGFQoFIp6AADSm/e12+7exOvc2IXROD5DFllkkeWVFV9S9wiXl5dr1apVWrNmjfLz8yP39BYUFKhr164qKCjQ7NmztXDhQhUVFSkUCumee+5RWVmZxo4dK0maOHGihg4dqjvuuEMrVqxQTU2NHnjgAZWXlysYDCYzHQBARorVqfHq8ARcxrhti5JFFllkyXrtLamF8JNPPilJGjduXNTxn//857rrrrskSY899piysrI0Y8YMNTU1adKkSfrxj38cGZudna1169Zp3rx5KisrU7du3TRr1iwtW7YsmakAADKeXcyc743jmKxxbsWRLLLIIitelkv62fweYb/we4QBpAq/R/jcif49ws6/S2cx8yp4Nuc5t+1QssgiqzNnpeT3CAMAkLxEtizDxc1rrFuhJIsssshKDgthAECKObc17S6PvcXptVXq/AxZZJFFlldWbCyEAQA+sAuXW0FzFsWAvIsfWWSRRZZXVmwshAEAKRawnr1eexVBu/CRRRZZZHllxcZPmgEAUixep8at22NvjSaSQxZZZJEVGx1hAIBP7EJld4Dcipmz0AVcjpNFFllkJbYIllgIAwB8EVD8gufsDtlbpnY3iCyyyCLLzoqPhTAAwAd28Uq8gxNd8MgiiyyyvLLiYyEMAEgDiXVvEituZJFFFlmJ4YflAABpxNn1CRc9t6KWaNEkiyyyOm9WfCyEAQApZnd17IIWLnLGeu8c43aOLLLIIiv8OrHuMbdGAABSzCi6mHktjMPnYxVDssgiiyyvrPjoCAMAfBKvWIWLm1uRi1UUySKLLLISWwzTEQYA+MCr22N3hWIdi9c5Iosssjp3Vnx0hAEAacJZyNy6OXaBjNXxIYsssjp3VmILYjrCAACfGOvZPm4LFz63QkcWWWSRZWfFx0IYAJAGnB2hMPu9sV57FTqyyCKLrMSwEAYApAm7G+TWHUr0/j+yyCKLrPi4RxgAkEac3ZyAdSzg8ZosssgiK5nP/xUdYQCAz+xuTlh4i9Sr0Ll9hiyyyCJLHmPPREcYAJBi4YLm7Nq4FS9jPbsdt4sfWWSRRZbbe3cshAEAKRbu4Djfu722u0P2OfsYWWSRRVZiC+AwFsIAAB/YHWHn8TC78Dk/41YIySKLLLK8PuOOe4QBAD7wKlTOYmjfQmEXyHgdILLIIqtzZ8XHQhgA4IOAx2spuqsTqxCGz5FFFllkdQwLYQBAijm7N3ZxsztB9nn7GFlkkUVWvCxvLIQBAD5wbmeGOzuyXoffS+6FMGC9JosssshKDj8sBwBIMXsr06vzEz5uF7dY26BkkUUWWfGy/oqOMAAgxeIVrHBRcz6TRRZZZHU0yxsLYQCAT+yujrOYuRU9ssgii6yzyToTC2EAQIq5FSuvAhavsJFFFllkdawbLLEQBgD4wuu+PnsMWWSRRda5yHLHQhgA4AO3rU274NldHrvgBaxxZJFFFlnJLYxZCAMAfJJIlyfgeMTa/iSLLLLIipd1Jn59GgDAJ4ncA+h8bxc5r9dkkUUWWYmhIwwASLFw50aK7vbY2512J8htC5QsssgiK1ZWbHSEAQAplkjnJ1ZXJ1z4jMd7ssgiiyx7gewuqY7w8uXLNXr0aOXn56u4uFjTp09XVVVV1Jhx48YpEAhEPe6+++6oMdXV1Zo6dary8vJUXFysRYsWqbW1NZmpAAAuKHb3x624JVbYyCKLLLISlVRHuKKiQuXl5Ro9erRaW1t1//33a+LEidqzZ4+6desWGTdnzhwtW7Ys8j4vLy/yuq2tTVOnTlVpaanefPNNHT58WHfeeadycnL0ne98J6nJAwAuFM4OULziFm8sWWSRRZZXpzhaUgvhl19+Oer9ypUrVVxcrG3btunGG2+MHM/Ly1Npaalrxm9/+1vt2bNHr776qkpKSnTNNdfo29/+thYvXqyHHnpIubm5yUwJAJCR3AparG3OWEWRLLLIIssrK7az+mG5+vp6SVJRUVHU8WeeeUY9e/bUsGHDtGTJEp06dSpyrrKyUsOHD1dJSUnk2KRJk9TQ0KDdu3e7/jlNTU1qaGiIegAA0pv3tdveugy/T7SIObdGySKLLLJiZcXW4R+Wa29v17333qsbbrhBw4YNixy//fbb1b9/f/Xp00c7duzQ4sWLVVVVpRdeeEGSVFNTE7UIlhR5X1NT4/pnLV++XA8//HBHpwoA8IH3tdvZ2bHfJ1PkjPWeLLLIIis5HV4Il5eXa9euXXrjjTeijs+dOzfyevjw4erdu7fGjx+v/fv3a9CgQR36s5YsWaKFCxdG3jc0NKhv374dmzgAICViX7vdipnXAtkeb3+OLLLIIitelrsOLYTnz5+vdevWafPmzbr00ktjjh0zZowkad++fRo0aJBKS0v19ttvR405cuSIJHneVxwMBhUMBjsyVQCAT2Jfu72KlFe3J1aBJIssssiKNd5bUvcIG2M0f/58rV69Whs3btSAAQPifmb79u2SpN69e0uSysrKtHPnTtXW1kbGrF+/XqFQSEOHDk1mOgCAjGffC5h8ISOLLLLI6qikOsLl5eVatWqV1qxZo/z8/Mg9vQUFBeratav279+vVatW6aabblKPHj20Y8cOLViwQDfeeKNGjBghSZo4caKGDh2qO+64QytWrFBNTY0eeOABlZeX0/UFgE7Dua0ZLnLG8ew2To7zXmPIIossspyL5tiS6gg/+eSTqq+v17hx49S7d+/I45e//KUkKTc3V6+++qomTpyoIUOG6L777tOMGTO0du3aSEZ2drbWrVun7OxslZWV6e/+7u905513Rv3eYQDAhcyt4IXFKm72a7LIIousRLK8JdURNiZ2cN++fVVRURE3p3///nrxxReT+aMBABeMWIXNWdTsTpFc3pNFFllkeWXF1+HfGgEAwLnh1slxK3ROdtEjiyyyyEreWf2DGgAAnFvh+wClM4udrONkkUUWWWeXxUIYAOCDgMdre6vTqztEFllkkZVMljsWwgAAH3gVLXur0yi6ADo/b3eEyCKLLLKSw0IYAOADu7sTPuZW+JzHvD5HFllkkeWWFRsLYQCAD5xFzd7+jFfEAtYzWWSRRZZXVmz81ggAQIqFi1h4SzPM7vJ4dX6c55wFkyyyyCIrOXSEAQA+cNv6lHXMbavTLopkkUUWWYlkuWMhDABIMWdxc3Z+3I65bZE635NFFllkeWXFx0IYAJBibp2c8HFnEbQLn1sGWWSRRZZXVnzcIwwASDG3LcuAy3Hj8treBiWLLLLIipUVGx1hAIBPkune2GONziycZJFFFlnJYSEMAPCJV/cm3lap2ziyyCKLLLfjsbEQBgCkWECx7wM0HueM9bnwe7LIIosst6z4WAgDAHwQq1gFrDHxukNkkUUWWW5Z8fHDcgCAFAsXLmdHx63TY792sosgWWSRRVby6AgDAHxkFzf7XKwtTvtzZJFFFlnJYSEMAEgzzo6Q1/lEO0BkkUVW582Kj4UwACDFwsXO3vaMV9jkcp4sssgiK16WNxbCAIAUCxe3sES2SN3GOAslWWSRRVZiXWAnflgOAJBi9vZnvCLmtV1qd4vIIossspJDRxgAkAbsDlCsYpfIGLLIIous+FgIAwB84ixmdgfIWOfkOO7W9SGLLLLIipd1JhbCAACfOIuZ3emxx9jcCh9ZZJFFVnJYCAMAfOBVyGIVNPszXtujZJFFFlmJ4YflAAApFt62DDjeO9kdIVnj3M6TRRZZZNlZ8bEQBgD4pOPbmZ9wFkKyyCKLrORxawQAIA04t0K9OjoBJVYgySKLLLISW0DTEQYApFisQhVrW9M+Z6zXZJFFFlmxzp2JjjAAwCfOQuUsfHYxdDtuF0eyyCKLLPt4fHSEAQA+iNXlcb52O0YWWWSRlWyWOzrCAIAUcxYqe/syoOgOT6ztzfBYssgii6yOYSEMAPCB1/al3eFxboXanzeO12SRRRZZyWMhDADwgbNw2V0dr+Ln1hUiiyyyyIqVFRv3CAMAfBDu+ji7P2GJHLe3TckiiyyynMcTw0IYAJBizuLlLFrOghbrOFlkkUVWMlnekro14sknn9SIESMUCoUUCoVUVlaml156KXL+9OnTKi8vV48ePdS9e3fNmDFDR44cicqorq7W1KlTlZeXp+LiYi1atEitra3JTAMAkNHCRcpZwLy6OOHjXh0essgii6x4Wd6SWghfeumleuSRR7Rt2zZt3bpVf/M3f6Np06Zp9+7dkqQFCxZo7dq1ev7551VRUaFDhw7p5ptvjny+ra1NU6dOVXNzs9588009/fTTWrlypZYuXZr0xAEAFxq7i+PsBtlFkSyyyCLrbLOkgDEm8f6xi6KiIj366KO65ZZb1KtXL61atUq33HKLJGnv3r266qqrVFlZqbFjx+qll17SF77wBR06dEglJSWSpKeeekqLFy/W0aNHlZubm9Cf2dDQoIKCAg2/eIyyA9zdAeD8aTOt2vnRFtXX1ysUCvk9nYwWvnbv2rpL+d3z//uo8x5AKbqQ2a+9nuUyjiyyyOrMWY0nTmjY9VfHvXZ3+LdGtLW16bnnntPJkydVVlambdu2qaWlRRMmTIiMGTJkiPr166fKykpJUmVlpYYPHx5ZBEvSpEmT1NDQEOkqu2lqalJDQ0PUAwCQ3mJfu+2C5uzmyGWcs8NjrGeyyCKLLK+s2JJeCO/cuVPdu3dXMBjU3XffrdWrV2vo0KGqqalRbm6uCgsLo8aXlJSopqZGklRTUxO1CA6fD5/zsnz5chUUFEQeffv2TXbaAIAUi33ttrs9ztduXSG3AmgXSbLIIousxBbAYUkvhAcPHqzt27dry5YtmjdvnmbNmqU9e/YkG5OUJUuWqL6+PvI4ePDgef3zAABnz/va7ezeeG15OnkVNufnyCKLLLKSl/QNtrm5ubr88sslSaNGjdI777yjH/zgB7r11lvV3Nysurq6qK7wkSNHVFpaKkkqLS3V22+/HZUX/q0S4TFugsGggsFgslMFAPjI+9ptd3HCnMfcuj1uyCKLLLLiZXk7639Zrr29XU1NTRo1apRycnK0YcOGyLmqqipVV1errKxMklRWVqadO3eqtrY2Mmb9+vUKhUIaOnTo2U4FAJCRwkUsXMicBc2tuAWsZ7LIIossr6zYkuoIL1myRFOmTFG/fv3U2NioVatWadOmTXrllVdUUFCg2bNna+HChSoqKlIoFNI999yjsrIyjR07VpI0ceJEDR06VHfccYdWrFihmpoaPfDAAyovL6fjCwCdirOL4yxi9jG3To9dCMkiiyyyvLJiS2ohXFtbqzvvvFOHDx9WQUGBRowYoVdeeUWf//znJUmPPfaYsrKyNGPGDDU1NWnSpEn68Y9/HPl8dna21q1bp3nz5qmsrEzdunXTrFmztGzZsmSmAQC4YMUrZslshZJFFlmdNysxZ/17hP3A7xEGkCr8HuFzx/33CNsSvdcvkXFkkUVWZ81qPNGoYdcPi3vtZhUJAEixgMtrYx13G2tvkzo/SxZZZJEVa4y7s/5hOQAAkmOs18bluD3W+WwXPbLIIousWFneWAgDAHwQ8HgOFzC3ghiwxtvdIrLIIoss+3hs3BoBAPCB3dWxuz+Se5GL1Q0iiyyyyAo/u+WdiY4wAMBHzmJld3GcxSyRDg9ZZJFFVnJYCAMAfBBwee3VwTGKLnbhz9jbpWSRRRZZiXWCw1gIAwB8YG9xehU/WefcMsgiiyyyvLJiYyEMAPCBV/FzFjlnB8ju8sTaKiWLLLLISgwLYQCAD+xi5+wAuXWDnGPJIossspLNcsdCGADgI2dxizUmLFa3iCyyyCLLzoqNhTAAwAfO7Uzne+dWp6wx9uftz5JFFllkJYeFMADAJwHrWYre8iSLLLLIOpdZZ+If1AAA+MAuYnbHR9Z7t/HhzhFZZJFFlltWfHSEAQA+cHZ5nMXLq9NjF77wWLLIIousWFmxsRAGAPjAOJ5jFS/jeNhbpHYXiCyyyCLLzoiNhTAAwEd2pyfM65hzvD2GLLLIIsvOio2FMADAR17FKmC9tjtEsbZTySKLLLISw0IYAOADZ9FyHguzC5m9Neo8TxZZZJHllhUfC2EAQIo5ty1jFUD7uFuXhyyyyCIrXpY3FsIAAB/YRc6ti2Nve3qNJYssssiKl+WO3yMMAEgxI+/uTcA6L2uciTGOLLLIIkse59zREQYA+CAg966N17ZnvHFkkUUWWYl1gZ3oCAMAfJJYxyZ6e9TrGFlkkUVWomP/io4wAMAHRt7dm4A1JvzsNZ4sssgiK16WOxbCAACfuHVvvLZA3e4FDLiMI4sssshKHAthAEAaMYouZrG6O/GKHllkkUVWbCyEAQApZheteFuj9mvnWLLIIouseFneWAgDAFLMLlR29yeZ7g5ZZJFFVsexEAYA+CAg7y6O8x7BgPWaLLLIIqsjWe5YCAMAUswuYtKZxcs4no3LMbLIIousRLO8sRAGAKSYW4HyKl6xujt2t4gsssgiKzkshAEAKeZWtOxj4ffG43z4HFlkkUVWrKzYWAgDAFLMq9MTLmLOblBA3sUtVteILLLIIis+FsIAgDTgLGp2AfS618/rOFlkkUVWYlgIAwBSLNy9cRauZIpYwHpNFllkkRUryxsLYQCAD7y2NO3iZTyOkUUWWWQlk+WOhTAAwAfhomWs927jTJxxZJFFFlnxstyxEAYApJhb1yfc0bE7O/HuAySLLLLIivdZbyyEAQA+cOvcGI9zAceDLLLIIqsjWe66JDU6TRjzyRffZlp9ngmAC134OhO+7qDjwn+HJ06c/O8jzs6OV8Gzt0blGGd3h8giiyyyPnHixIlPjsS5dmfkQrixsVGStKdum88zAdBZNDY2qqCgwO9pZLRjx45JksaOG+PzTAB0FvGu3QGTgW2O9vZ2VVVVaejQoTp48KBCoZDfU0pKQ0OD+vbty9x9kMnzZ+7+MMaosbFRffr0UVYWd5Odjbq6Ol188cWqrq7OuP9Tkcnfw1Jmz5+5+yeT55/otTsjO8JZWVm65JJLJEmhUCjj/uOEMXf/ZPL8mXvqZdqiLV2Fi1FBQUFGfh9Imfs9HJbJ82fu/snU+Sdy7aa9AQAAgE6JhTAAAAA6pYxdCAeDQT344IMKBoN+TyVpzN0/mTx/5o5Ml8nfB5k8dymz58/c/ZPp809ERv6wHAAAAHC2MrYjDAAAAJwNFsIAAADolFgIAwAAoFNiIQwAAIBOKSMXwk888YQuu+wyXXTRRRozZozefvttv6d0hoceekiBQCDqMWTIkMj506dPq7y8XD169FD37t01Y8YMHTlyxLf5bt68WV/84hfVp08fBQIB/eY3v4k6b4zR0qVL1bt3b3Xt2lUTJkzQe++9FzXm+PHjmjlzpkKhkAoLCzV79uzIv/Xt59zvuuuuM/5bTJ48OS3mvnz5co0ePVr5+fkqLi7W9OnTVVVVFTUmke+V6upqTZ06VXl5eSouLtaiRYvU2trq+9zHjRt3xt/93Xff7fvckXqZcN2WMuvazXWb6/b5mn9nunZn3EL4l7/8pRYuXKgHH3xQv//97zVy5EhNmjRJtbW1fk/tDFdffbUOHz4cebzxxhuRcwsWLNDatWv1/PPPq6KiQocOHdLNN9/s21xPnjypkSNH6oknnnA9v2LFCj3++ON66qmntGXLFnXr1k2TJk3S6dOnI2Nmzpyp3bt3a/369Vq3bp02b96suXPn+j53SZo8eXLUf4tnn3026rxfc6+oqFB5ebneeustrV+/Xi0tLZo4caJOnjwZGRPve6WtrU1Tp05Vc3Oz3nzzTT399NNauXKlli5d6vvcJWnOnDlRf/crVqzwfe5IrUy6bkuZc+3mus11+3zNX+pE126TYT71qU+Z8vLyyPu2tjbTp08fs3z5ch9ndaYHH3zQjBw50vVcXV2dycnJMc8//3zk2B//+EcjyVRWVqZoht4kmdWrV0fet7e3m9LSUvPoo49GjtXV1ZlgMGieffZZY4wxe/bsMZLMO++8Exnz0ksvmUAgYD744APf5m6MMbNmzTLTpk3z/Ey6zN0YY2pra40kU1FRYYxJ7HvlxRdfNFlZWaampiYy5sknnzShUMg0NTX5NndjjPnsZz9rvv71r3t+Jl3mjvMrU67bxmTutZvrNtftczV/YzrXtTujOsLNzc3atm2bJkyYEDmWlZWlCRMmqLKy0seZuXvvvffUp08fDRw4UDNnzlR1dbUkadu2bWppaYn6OoYMGaJ+/fql5ddx4MAB1dTURM23oKBAY8aMicy3srJShYWFuv766yNjJkyYoKysLG3ZsiXlc7Zt2rRJxcXFGjx4sObNm6djx45FzqXT3Ovr6yVJRUVFkhL7XqmsrNTw4cNVUlISGTNp0iQ1NDRo9+7dvs097JlnnlHPnj01bNgwLVmyRKdOnYqcS5e54/zJtOu2dGFcu7lup04mX7clrt1d/J5AMj788EO1tbVF/cVLUklJifbu3evTrNyNGTNGK1eu1ODBg3X48GE9/PDD+sxnPqNdu3appqZGubm5KiwsjPpMSUmJampq/JlwDOE5uf29h8/V1NSouLg46nyXLl1UVFTk+9c0efJk3XzzzRowYID279+v+++/X1OmTFFlZaWys7PTZu7t7e269957dcMNN2jYsGGSlND3Sk1Njet/m/C5VHCbuyTdfvvt6t+/v/r06aMdO3Zo8eLFqqqq0gsvvJA2c8f5lUnXbenCuXZz3U6NTL5uS1y7pQxbCGeSKVOmRF6PGDFCY8aMUf/+/fWrX/1KXbt29XFmnc9tt90WeT18+HCNGDFCgwYN0qZNmzR+/HgfZxatvLxcu3btirofMVN4zd15v97w4cPVu3dvjR8/Xvv379egQYNSPU0gLq7d6YHrdmpw7c6wH5br2bOnsrOzz/jJyyNHjqi0tNSnWSWmsLBQV155pfbt26fS0lI1Nzerrq4uaky6fh3hOcX6ey8tLT3jB19aW1t1/PjxtPuaBg4cqJ49e2rfvn2S0mPu8+fP17p16/Taa6/p0ksvjRxP5HultLTU9b9N+Nz55jV3N2PGjJGkqL97P+eO8y+Tr9tS5l67uW6ff5l83Za4dodl1EI4NzdXo0aN0oYNGyLH2tvbtWHDBpWVlfk4s/hOnDih/fv3q3fv3ho1apRycnKivo6qqipVV1en5dcxYMAAlZaWRs23oaFBW7Zsicy3rKxMdXV12rZtW2TMxo0b1d7eHvkfULp4//33dezYMfXu3VuSv3M3xmj+/PlavXq1Nm7cqAEDBkSdT+R7paysTDt37owqCuvXr1coFNLQoUN9m7ub7du3S1LU370fc0fqZPJ1W8rcazfX7fMnk6/biczfzQV97fb3Z/WS99xzz5lgMGhWrlxp9uzZY+bOnWsKCwujfnIxHdx3331m06ZN5sCBA+Z3v/udmTBhgunZs6epra01xhhz9913m379+pmNGzearVu3mrKyMlNWVubbfBsbG827775r3n33XSPJfP/73zfvvvuu+ctf/mKMMeaRRx4xhYWFZs2aNWbHjh1m2rRpZsCAAebjjz+OZEyePNlce+21ZsuWLeaNN94wV1xxhfnKV77i69wbGxvNN77xDVNZWWkOHDhgXn31VXPdddeZK664wpw+fdr3uc+bN88UFBSYTZs2mcOHD0cep06dioyJ973S2tpqhg0bZiZOnGi2b99uXn75ZdOrVy+zZMkSX+e+b98+s2zZMrN161Zz4MABs2bNGjNw4EBz4403+j53pFamXLeNyaxrN9dtrtvnY/6d7dqdcQthY4z54Q9/aPr162dyc3PNpz71KfPWW2/5PaUz3HrrraZ3794mNzfXXHLJJebWW281+/bti5z/+OOPzde+9jVz8cUXm7y8PPPlL3/ZHD582Lf5vvbaa0bSGY9Zs2YZYz75VTzf+ta3TElJiQkGg2b8+PGmqqoqKuPYsWPmK1/5iunevbsJhULmq1/9qmlsbPR17qdOnTITJ040vXr1Mjk5OaZ///5mzpw5ZxRgv+buNm9J5uc//3lkTCLfK3/+85/NlClTTNeuXU3Pnj3NfffdZ1paWnyde3V1tbnxxhtNUVGRCQaD5vLLLzeLFi0y9fX1vs8dqZcJ121jMuvazXWb6/b5mH9nu3YHjDHm3PeZAQAAgPSWUfcIAwAAAOcKC2EAAAB0SiyEAQAA0CmxEAYAAECnxEIYAAAAnRILYQAAAHRKLIQBAADQKbEQBgAAQKfEQhgAAACdEgthAAAAdEoshAEAANApsRAGAABAp/T/AXaRCBBEOqF7AAAAAElFTkSuQmCC", 110 | "text/plain": [ 111 | "
" 112 | ] 113 | }, 114 | "metadata": {}, 115 | "output_type": "display_data" 116 | } 117 | ], 118 | "source": [ 119 | "display_curvelet(\n", 120 | " scale=3,\n", 121 | " wedge=3,\n", 122 | " ix=y_struct[2][2].shape[1] // 2 + 1,\n", 123 | " iy=y_struct[2][2].shape[0] // 2 + 1,\n", 124 | ")" 125 | ] 126 | }, 127 | { 128 | "cell_type": "markdown", 129 | "metadata": {}, 130 | "source": [ 131 | "### Interactive" 132 | ] 133 | }, 134 | { 135 | "cell_type": "code", 136 | "execution_count": 5, 137 | "metadata": { 138 | "scrolled": false 139 | }, 140 | "outputs": [ 141 | { 142 | "data": { 143 | "application/vnd.jupyter.widget-view+json": { 144 | "model_id": "0683c8c7a7664e43a36bff56dcad8383", 145 | "version_major": 2, 146 | "version_minor": 0 147 | }, 148 | "text/plain": [ 149 | "HBox(children=(VBox(children=(IntSlider(value=1, description='Scales', max=6, min=1), IntSlider(value=1, descr…" 150 | ] 151 | }, 152 | "metadata": {}, 153 | "output_type": "display_data" 154 | }, 155 | { 156 | "data": { 157 | "application/vnd.jupyter.widget-view+json": { 158 | "model_id": "74fb7044b37c446b87f31be817ad2b4d", 159 | "version_major": 2, 160 | "version_minor": 0 161 | }, 162 | "text/plain": [ 163 | "Output()" 164 | ] 165 | }, 166 | "metadata": {}, 167 | "output_type": "display_data" 168 | } 169 | ], 170 | "source": [ 171 | "max_scale = DCT.nbscales\n", 172 | "max_wedge = len(y_struct[0])\n", 173 | "max_iy, max_ix = y_struct[0][0].shape\n", 174 | "curr_scale = 1\n", 175 | "curr_wedge = 1\n", 176 | "\n", 177 | "slider_scale = IntSlider(\n", 178 | " min=1, max=max_scale, value=curr_scale, step=1, description=\"Scales\"\n", 179 | ")\n", 180 | "slider_wedge = IntSlider(\n", 181 | " min=1, max=max_wedge, value=curr_wedge, step=1, description=\"Wedge\"\n", 182 | ")\n", 183 | "slider_ix = IntSlider(\n", 184 | " min=1, max=max_ix, value=max_ix // 2 + 1, step=1, description=\"X Index\"\n", 185 | ")\n", 186 | "slider_iy = IntSlider(\n", 187 | " min=1, max=max_iy, value=max_iy // 2 + 1, step=1, description=\"Y Index\"\n", 188 | ")\n", 189 | "\n", 190 | "\n", 191 | "def handle_scale_change(change):\n", 192 | " global curr_scale\n", 193 | " curr_scale = change.new\n", 194 | " slider_wedge.max = len(y_struct[curr_scale - 1])\n", 195 | " global curr_wedge\n", 196 | " curr_wedge = slider_wedge.value\n", 197 | " A, B = y_struct[curr_scale - 1][curr_wedge - 1].shape\n", 198 | " slider_ix.max = B\n", 199 | " slider_iy.max = A\n", 200 | "\n", 201 | "\n", 202 | "def handle_wedge_change(change):\n", 203 | " global curr_wedge\n", 204 | " curr_wedge = change.new\n", 205 | " A, B = y_struct[curr_scale - 1][curr_wedge - 1].shape\n", 206 | " slider_ix.max = B\n", 207 | " slider_iy.max = A\n", 208 | "\n", 209 | "\n", 210 | "slider_scale.observe(handle_scale_change, names=\"value\")\n", 211 | "slider_wedge.observe(handle_wedge_change, names=\"value\")\n", 212 | "\n", 213 | "out = interactive_output(\n", 214 | " display_curvelet,\n", 215 | " {\n", 216 | " \"scale\": slider_scale,\n", 217 | " \"wedge\": slider_wedge,\n", 218 | " \"ix\": slider_ix,\n", 219 | " \"iy\": slider_iy,\n", 220 | " },\n", 221 | ")\n", 222 | "vbox1 = VBox([slider_scale, slider_wedge])\n", 223 | "vbox2 = VBox([slider_ix, slider_iy])\n", 224 | "ui = HBox([vbox1, vbox2])\n", 225 | "display(ui, out)" 226 | ] 227 | }, 228 | { 229 | "cell_type": "code", 230 | "execution_count": null, 231 | "metadata": {}, 232 | "outputs": [], 233 | "source": [] 234 | } 235 | ], 236 | "metadata": { 237 | "@webio": { 238 | "lastCommId": null, 239 | "lastKernelId": null 240 | }, 241 | "kernelspec": { 242 | "display_name": "Python 3 (ipykernel)", 243 | "language": "python", 244 | "name": "python3" 245 | }, 246 | "language_info": { 247 | "codemirror_mode": { 248 | "name": "ipython", 249 | "version": 3 250 | }, 251 | "file_extension": ".py", 252 | "mimetype": "text/x-python", 253 | "name": "python", 254 | "nbconvert_exporter": "python", 255 | "pygments_lexer": "ipython3", 256 | "version": "3.10.6" 257 | }, 258 | "vscode": { 259 | "interpreter": { 260 | "hash": "2a97748138635e07aa5d1e3bf79826285d6ad6d370bfec5306f9f799b8f29eef" 261 | } 262 | } 263 | }, 264 | "nbformat": 4, 265 | "nbformat_minor": 4 266 | } 267 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools", 4 | "wheel", 5 | "pybind11>=2.6.0; python_version < '3.11'", 6 | "pybind11>=2.10.0; python_version >= '3.11'", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [tool.black] 11 | line-length = 88 12 | 13 | [tool.isort] 14 | profile = "black" 15 | 16 | [[tool.mypy.overrides]] 17 | module = [ 18 | "curvelops._version", 19 | "curvelops.fdct2d_wrapper", 20 | "curvelops.fdct3d_wrapper", 21 | "pylops", 22 | "pylops.*", 23 | "matplotlib", 24 | "matplotlib.*", 25 | "mpl_toolkits.axes_grid1", 26 | "scipy.*", 27 | "tqdm.*", 28 | ] 29 | ignore_missing_imports = true 30 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Install requires 2 | numpy>=1.21.0 3 | scipy>=1.9.1; python_version >= '3.9' 4 | pylops>=2.0 5 | # Setup requires 6 | pybind11>=2.6.0; python_version < '3.10' 7 | pybind11>=2.10.0; python_version >= '3.11' 8 | setuptools_scm 9 | # Tests require 10 | pytest 11 | # Docs require 12 | Sphinx 13 | pydata-sphinx-theme 14 | sphinx-gallery 15 | sphinx-copybutton 16 | # Dev requires 17 | pre-commit 18 | # Lint 19 | flake8 20 | mypy 21 | coverage 22 | # Examples require 23 | matplotlib 24 | tqdm 25 | ipywidgets 26 | ipython 27 | ipympl 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | . 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = --verbose 3 | 4 | [flake8] 5 | ignore = E203, E501, W503, E402 6 | per-file-ignores = 7 | __init__.py: F401, F403, F405 8 | max-line-length = 88 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from setuptools import find_packages, setup 5 | 6 | if "clean" in sys.argv: 7 | from pathlib import Path 8 | 9 | # Delete any previously compiled files in pygeos 10 | p = Path("curvelops") 11 | for filename in p.glob("*.so"): 12 | print("removing '{}'".format(filename)) 13 | filename.unlink() 14 | 15 | from pybind11.setup_helpers import Pybind11Extension, build_ext 16 | 17 | NAME = "curvelops" 18 | AUTHOR = "Carlos Alberto da Costa Filho" 19 | AUTHOR_EMAIL = "c.dacostaf@gmail.com" 20 | URL = "https://github.com/PyLops/curvelops" 21 | DESCRIPTION = "Python wrapper for CurveLab's 2D and 3D curvelet transforms" 22 | LICENSE = "MIT" 23 | 24 | with open("README.md", encoding="utf-8") as f: 25 | LONG_DESCRIPTION = f.read() 26 | 27 | try: 28 | FFTW = os.environ["FFTW"] 29 | except KeyError: 30 | print( 31 | """ 32 | ============================================================== 33 | 34 | Please ensure the FFTW environment variable is set to the root 35 | of the FFTW 2.1.5 installation directory. 36 | 37 | ============================================================== 38 | """ 39 | ) 40 | try: 41 | FDCT = os.environ["FDCT"] 42 | except KeyError: 43 | print( 44 | """ 45 | ============================================================== 46 | 47 | Please ensure the FDCT environment variable is set to the root 48 | of the CurveLab installation directory. 49 | 50 | ============================================================== 51 | """ 52 | ) 53 | 54 | 55 | ext_modules = [ 56 | Pybind11Extension( 57 | "fdct2d_wrapper", 58 | [os.path.join("cpp", "fdct2d_wrapper.cpp")], 59 | include_dirs=[ 60 | os.path.join(FFTW, "fftw"), 61 | os.path.join(FDCT, "fdct_wrapping_cpp", "src"), 62 | ], 63 | libraries=["fftw"], 64 | library_dirs=[os.path.join(FFTW, "fftw", ".libs")], 65 | extra_objects=[ 66 | os.path.join(FDCT, "fdct_wrapping_cpp", "src", "libfdct_wrapping.a") 67 | ], 68 | language="c++", 69 | ), 70 | Pybind11Extension( 71 | "fdct3d_wrapper", 72 | [os.path.join("cpp", "fdct3d_wrapper.cpp")], 73 | include_dirs=[ 74 | os.path.join(FFTW, "fftw"), 75 | os.path.join(FDCT, "fdct3d", "src"), 76 | ], 77 | libraries=["fftw"], 78 | library_dirs=[os.path.join(FFTW, "fftw", ".libs")], 79 | extra_objects=[os.path.join(FDCT, "fdct3d", "src", "libfdct3d.a")], 80 | language="c++", 81 | ), 82 | ] 83 | 84 | # Remove -stdlib=libc++ from MACOS flags if MACOS_GCC flag is equal to 1 85 | # (This is required because pybind11 assumes OSX will use clang compiler but 86 | # FFTW and FDCT may require switching to a gcc compiler in some OSX versions. 87 | MACOS = sys.platform.startswith("darwin") 88 | if MACOS and int(os.getenv("MACOS_GCC", 0)) == 1: 89 | for ext in ext_modules: 90 | new_flags = [] 91 | for flag in ext.extra_compile_args: 92 | if flag != "-stdlib=libc++": 93 | new_flags.append(flag) 94 | ext.extra_compile_args = new_flags 95 | 96 | new_flags = [] 97 | for flag in ext.extra_link_args: 98 | if flag != "-stdlib=libc++": 99 | new_flags.append(flag) 100 | ext.extra_link_args = new_flags 101 | 102 | setup( 103 | name=NAME, 104 | author=AUTHOR, 105 | author_email=AUTHOR_EMAIL, 106 | url=URL, 107 | description=DESCRIPTION, 108 | long_description=LONG_DESCRIPTION, 109 | long_description_content_type="text/markdown", 110 | zip_safe=False, 111 | include_package_data=True, 112 | cmdclass={"build_ext": build_ext}, 113 | ext_package="curvelops", 114 | ext_modules=ext_modules, 115 | packages=find_packages(exclude=["pytests"]), 116 | install_requires=[ 117 | "numpy>=1.21.0", 118 | "scipy>=1.9.1; python_version >= '3.9'", 119 | "pylops>=2.0", 120 | "matplotlib", 121 | ], 122 | setup_requires=[ 123 | "pybind11>=2.6.0; python_version < '3.10'", 124 | "pybind11>=2.10.0; python_version >= '3.11'", 125 | "setuptools_scm", 126 | ], 127 | use_scm_version=dict( 128 | root=".", relative_to=__file__, write_to=f"{NAME}/_version.py" 129 | ), 130 | license=LICENSE, 131 | test_suite="pytests", 132 | tests_require=["pytest"], 133 | extras_require={"dev": ["pytest"]}, 134 | python_requires=">=3.7", 135 | classifiers=[ 136 | "Development Status :: 3 - Beta", 137 | "Intended Audience :: Science/Research", 138 | "License :: OSI Approved :: MIT License", 139 | "Natural Language :: English", 140 | "Programming Language :: Python :: 3.7", 141 | "Programming Language :: Python :: 3.8", 142 | "Programming Language :: Python :: 3.9", 143 | "Programming Language :: Python :: 3.10", 144 | "Programming Language :: Python :: 3.11", 145 | "Topic :: Scientific/Engineering :: Mathematics", 146 | ], 147 | keywords="curvelet curvelab pylops", 148 | ) 149 | -------------------------------------------------------------------------------- /testdata/python.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyLops/curvelops/d6dc5fde8bcf399e57f81c5beb38449eb8863e45/testdata/python.png -------------------------------------------------------------------------------- /testdata/seismic.npz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyLops/curvelops/d6dc5fde8bcf399e57f81c5beb38449eb8863e45/testdata/seismic.npz -------------------------------------------------------------------------------- /testdata/sigmoid.npz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyLops/curvelops/d6dc5fde8bcf399e57f81c5beb38449eb8863e45/testdata/sigmoid.npz -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyLops/curvelops/d6dc5fde8bcf399e57f81c5beb38449eb8863e45/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_fdct.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from pylops.utils import dottest 4 | 5 | from curvelops import FDCT2D, FDCT3D 6 | 7 | PYCT = False 8 | try: 9 | import pyct as ct 10 | 11 | PYCT = True 12 | print( 13 | """ 14 | Imported `pyct` 15 | """ 16 | ) 17 | 18 | except ImportError: 19 | print( 20 | """ 21 | Could not import `pyct` (PyCurvelab), will proceed without 22 | checking if both libraries match 23 | """ 24 | ) 25 | 26 | pars = [ 27 | # {'nx': 32, 'ny': 32, 'nz': 32, 'imag': 0, 'dtype': 'float64'}, 28 | {"nx": 32, "ny": 32, "nz": 32, "imag": 1j, "dtype": "complex128"}, 29 | # {'nx': 32, 'ny': 32, 'nz': 64, 'imag': 0, 'dtype': 'float64'}, 30 | {"nx": 32, "ny": 32, "nz": 64, "imag": 1j, "dtype": "complex128"}, 31 | # {'nx': 100, 'ny': 50, 'nz': 20, 'imag': 0, 'dtype': 'complex128'}, 32 | {"nx": 100, "ny": 50, "nz": 20, "imag": 1j, "dtype": "complex128"}, 33 | ] 34 | 35 | 36 | @pytest.mark.parametrize("par", pars) 37 | def test_FDCT2D_2dsignal(par): 38 | """ 39 | Tests for FDCT2D operator for 2d signal. 40 | """ 41 | x = ( 42 | np.random.normal(0.0, 1.0, (par["nx"], par["ny"])) 43 | + np.random.normal(0.0, 1.0, (par["nx"], par["ny"])) * par["imag"] 44 | ) 45 | 46 | FDCTop = FDCT2D(dims=(par["nx"], par["ny"]), dtype=par["dtype"]) 47 | 48 | assert dottest( 49 | FDCTop, *FDCTop.shape, rtol=1e-12, complexflag=0 if par["imag"] == 0 else 3 50 | ) 51 | 52 | y = FDCTop * x.ravel() 53 | xinv = FDCTop.H * y 54 | np.testing.assert_array_almost_equal(xinv.reshape(*x.shape), x, decimal=14) 55 | 56 | if PYCT: 57 | FDCTct = ct.fdct2( 58 | x.shape, 59 | FDCTop.nbscales, 60 | FDCTop.nbangles_coarse, 61 | FDCTop.allcurvelets, 62 | cpx=False if par["imag"] == 0 else True, 63 | ) 64 | y_ct = np.array(FDCTct.fwd(x)).ravel() 65 | 66 | np.testing.assert_array_almost_equal(y, y_ct, decimal=64) 67 | assert y.dtype == y_ct.dtype 68 | 69 | 70 | @pytest.mark.parametrize("par", pars) 71 | def test_FDCT2D_3dsignal(par): 72 | """ 73 | Tests for FDCT2D operator for 3d signal. 74 | """ 75 | x = ( 76 | np.random.normal(0.0, 1.0, (par["nx"], par["ny"], par["nz"])) 77 | + np.random.normal(0.0, 1.0, (par["nx"], par["ny"], par["nz"])) * par["imag"] 78 | ) 79 | axes = [0, -1] 80 | FDCTop = FDCT2D( 81 | dims=(par["nx"], par["ny"], par["nz"]), axes=axes, dtype=par["dtype"] 82 | ) 83 | 84 | assert dottest( 85 | FDCTop, *FDCTop.shape, rtol=1e-12, complexflag=0 if par["imag"] == 0 else 3 86 | ) 87 | 88 | y = FDCTop * x.ravel() 89 | xinv = FDCTop.H * y 90 | np.testing.assert_array_almost_equal(xinv.reshape(*x.shape), x, decimal=14) 91 | 92 | 93 | @pytest.mark.parametrize("par", pars) 94 | def test_FDCT3D_3dsignal(par): 95 | """ 96 | Tests for FDCT3D operator for 3d signal. 97 | """ 98 | x = ( 99 | np.random.normal(0.0, 1.0, (par["nx"], par["ny"], par["nz"])) 100 | + np.random.normal(0.0, 1.0, (par["nx"], par["ny"], par["nz"])) * par["imag"] 101 | ) 102 | 103 | FDCTop = FDCT3D(dims=(par["nx"], par["ny"], par["nz"]), dtype=par["dtype"]) 104 | 105 | assert dottest( 106 | FDCTop, *FDCTop.shape, rtol=1e-12, complexflag=0 if par["imag"] == 0 else 3 107 | ) 108 | 109 | y = FDCTop * x.ravel() 110 | xinv = FDCTop.H * y 111 | np.testing.assert_array_almost_equal(xinv.reshape(*x.shape), x, decimal=14) 112 | 113 | if PYCT: 114 | FDCTct = ct.fdct3( 115 | x.shape, 116 | FDCTop.nbscales, 117 | FDCTop.nbangles_coarse, 118 | FDCTop.allcurvelets, 119 | cpx=False if par["imag"] == 0 else True, 120 | ) 121 | 122 | y_ct = np.array(FDCTct.fwd(x)).ravel() 123 | 124 | np.testing.assert_array_almost_equal(y, y_ct, decimal=64) 125 | assert y.dtype == y_ct.dtype 126 | 127 | 128 | @pytest.mark.parametrize("par", pars) 129 | def test_FDCT3D_4dsignal(par): 130 | """ 131 | Tests for FDCT3D operator for 4d signal. 132 | """ 133 | x = ( 134 | np.random.normal(0.0, 1.0, (par["nx"], 4, par["ny"], par["nz"])) 135 | + np.random.normal(0.0, 1.0, (par["nx"], 4, par["ny"], par["nz"])) * par["imag"] 136 | ) 137 | axes = [0, -2, -1] 138 | FDCTop = FDCT3D( 139 | dims=(par["nx"], 4, par["ny"], par["nz"]), 140 | axes=axes, 141 | dtype=par["dtype"], 142 | ) 143 | 144 | assert dottest( 145 | FDCTop, *FDCTop.shape, rtol=1e-12, complexflag=0 if par["imag"] == 0 else 3 146 | ) 147 | 148 | x = x.ravel() 149 | y = FDCTop * x 150 | xinv = FDCTop.H * y 151 | np.testing.assert_array_almost_equal(xinv, x, decimal=14) 152 | -------------------------------------------------------------------------------- /tests/test_fdct2d_wrapper.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | import curvelops.fdct2d_wrapper as ct 5 | 6 | pars = [ 7 | {"nx": 100, "ny": 50, "imag": 0, "dtype": "float64"}, 8 | {"nx": 100, "ny": 50, "imag": 1j, "dtype": "float64"}, 9 | {"nx": 256, "ny": 256, "imag": 0, "dtype": "float64"}, 10 | {"nx": 256, "ny": 256, "imag": 1j, "dtype": "float64"}, 11 | {"nx": 512, "ny": 256, "imag": 0, "dtype": "float64"}, 12 | {"nx": 512, "ny": 256, "imag": 1j, "dtype": "float64"}, 13 | {"nx": 512, "ny": 512, "imag": 0, "dtype": "float64"}, 14 | {"nx": 512, "ny": 512, "imag": 1j, "dtype": "complex128"}, 15 | ] 16 | 17 | 18 | @pytest.mark.parametrize("par", pars) 19 | def test_FDCT2D_wrapper_2dsignal(par): 20 | x = ( 21 | np.random.normal(0, 1, (par["nx"], par["ny"])) 22 | + np.random.normal(0, 1, (par["nx"], par["ny"])) * par["imag"] 23 | ) 24 | 25 | for nbscales in [4, 6, 8, 16]: 26 | for nbangles_coarse in [8, 16]: 27 | for ac in [True, False]: 28 | c = ct.fdct2d_forward_wrap(nbscales, nbangles_coarse, ac, x) 29 | xinv = ct.fdct2d_inverse_wrap( 30 | *x.shape, nbscales, nbangles_coarse, ac, c 31 | ) 32 | np.testing.assert_array_almost_equal(x, xinv, decimal=12) 33 | np.testing.assert_array_almost_equal( 34 | 2.0 * np.sum(np.abs(x - xinv)) / np.sum(np.abs(x + xinv)), 35 | 0.0, 36 | decimal=12, 37 | ) 38 | -------------------------------------------------------------------------------- /tests/test_fdct3d_wrapper.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | import curvelops.fdct3d_wrapper as ct 5 | 6 | pars = [ 7 | {"nx": 32, "ny": 32, "nz": 32, "imag": 0, "dtype": "float64"}, 8 | {"nx": 32, "ny": 32, "nz": 32, "imag": 1j, "dtype": "complex128"}, 9 | {"nx": 32, "ny": 32, "nz": 64, "imag": 0, "dtype": "float64"}, 10 | {"nx": 32, "ny": 32, "nz": 64, "imag": 1j, "dtype": "complex128"}, 11 | {"nx": 100, "ny": 50, "nz": 20, "imag": 0, "dtype": "float64"}, 12 | {"nx": 100, "ny": 50, "nz": 20, "imag": 1j, "dtype": "complex128"}, 13 | ] 14 | 15 | 16 | @pytest.mark.parametrize("par", pars) 17 | def test_FDCT3D_wrapper_3dsignal(par): 18 | x = ( 19 | np.random.normal(0, 1, (par["nx"], par["ny"], par["nz"])) 20 | + np.random.normal(0, 1, (par["nx"], par["ny"], par["nz"])) * par["imag"] 21 | ) 22 | for nbscales in [4, 6, 8]: 23 | for nbangles_coarse in [8, 16]: 24 | for ac in [True, False]: 25 | c = ct.fdct3d_forward_wrap(nbscales, nbangles_coarse, ac, x) 26 | xinv = ct.fdct3d_inverse_wrap( 27 | *x.shape, nbscales, nbangles_coarse, ac, c 28 | ) 29 | np.testing.assert_array_almost_equal(x, xinv, decimal=12) 30 | np.testing.assert_array_almost_equal( 31 | 2.0 * np.sum(np.abs(x - xinv)) / np.sum(np.abs(x + xinv)), 32 | 0.0, 33 | decimal=12, 34 | ) 35 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from numpy.random import randint 4 | 5 | from curvelops import FDCT 6 | from curvelops.utils import ( 7 | apply_along_wedges, 8 | array_split_nd, 9 | energy, 10 | energy_split, 11 | ndargmax, 12 | split_nd, 13 | ) 14 | 15 | pars = [ 16 | {"shape": (randint(1, 99),), "splits": (randint(1, 10),)}, 17 | { 18 | "shape": (randint(1, 99), randint(1, 99)), 19 | "splits": (randint(1, 10), randint(1, 10)), 20 | }, 21 | { 22 | "shape": (randint(1, 99), randint(1, 99), randint(1, 99)), 23 | "splits": (randint(1, 10), randint(1, 10), randint(1, 10)), 24 | }, 25 | ] 26 | 27 | pars_cl = [ 28 | {"shape": (randint(32, 129), randint(32, 129))}, 29 | {"shape": (randint(32, 129), randint(32, 129), randint(32, 129))}, 30 | ] 31 | 32 | 33 | def test_array_split_nd_simple(): 34 | x = np.outer(1 + np.arange(2), 2 + np.arange(3)) 35 | y = array_split_nd(x, 2, 3) 36 | assert len(x) == 2 37 | for subx in x: 38 | assert len(subx) == 3 39 | assert y[0][0] == 2 40 | assert y[0][1] == 3 41 | assert y[0][2] == 4 42 | assert y[1][0] == 4 43 | assert y[1][1] == 6 44 | assert y[1][2] == 8 45 | 46 | 47 | @pytest.mark.parametrize("par", pars) 48 | def test_array_split_nd_sizes(par): 49 | shape = par["shape"] 50 | splits = par["splits"] 51 | x = np.zeros(tuple(a * b for (a, b) in zip(shape, splits))) 52 | y = array_split_nd(x, *splits) 53 | for split in splits: 54 | assert split == len(y) 55 | y = y[0] 56 | assert y.shape == shape 57 | 58 | 59 | @pytest.mark.parametrize("par", pars) 60 | def test_split_nd_sizes(par): 61 | shape = par["shape"] 62 | splits = par["splits"] 63 | x = np.zeros(tuple(a * b for (a, b) in zip(shape, splits))) 64 | y = split_nd(x, *splits) 65 | for split in splits: 66 | assert split == len(y) 67 | y = y[0] 68 | assert y.shape == shape 69 | 70 | 71 | @pytest.mark.parametrize("par", pars_cl) 72 | def test_apply_along_wedges(par): 73 | shape = par["shape"] 74 | Cop = FDCT(shape, axes=list(range(len(shape)))) 75 | x = np.random.normal(0.0, 1.0, shape) + np.random.normal(0.0, 1.0, shape) * 1j 76 | # Create a vector of curvelet coeffs 77 | y = Cop @ x 78 | # Convert to structure 79 | y_struct = Cop.struct(Cop @ x) 80 | # Add 1 to each wedge 81 | y_struct_one = apply_along_wedges( 82 | y_struct, 83 | lambda c, w, s, na, ns: c + 1.0, 84 | ) 85 | # Convert back to vector 86 | y_one = Cop.vect(y_struct_one) 87 | 88 | # Ensure that each wedge of the modified wedge - original is 89 | # equal to 2d array of ones 90 | apply_along_wedges( 91 | Cop.struct(y_one - y), 92 | lambda c, w, s, na, ns: np.testing.assert_allclose(c, np.ones_like(c)), 93 | ) 94 | 95 | 96 | def test_energy(): 97 | ndim = np.random.randint(1, 10) 98 | shape = [np.random.randint(1, 10) for _ in range(ndim)] 99 | ones = np.ones(shape) 100 | e = energy(ones) 101 | np.testing.assert_allclose(1.0, e) 102 | 103 | 104 | def test_energy_split(): 105 | shape = [np.random.randint(1, 100), np.random.randint(1, 100)] 106 | rows, cols = np.random.randint(1, shape[0]), np.random.randint(1, shape[1]) 107 | ones = np.ones(shape) 108 | e = energy_split(ones, rows, cols) 109 | for row in range(rows): 110 | for col in range(cols): 111 | np.testing.assert_allclose(1.0, e[row][col]) 112 | 113 | 114 | def test_ndargmax(): 115 | ndim = np.random.randint(1, 10) 116 | shape = [np.random.randint(1, 10) for _ in range(ndim)] 117 | ary = np.zeros(shape) 118 | index = tuple([np.random.randint(0, shape[i]) for i in range(ndim)]) 119 | ary[index] = 1.0 120 | assert index == ndargmax(ary) 121 | --------------------------------------------------------------------------------