diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..498eee0 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,18 @@ +name: Publish + +on: + push: + tags: + - v** + +jobs: + pypi-publish: + name: upload release to PyPI + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/checkout@v3 + - uses: pdm-project/setup-pdm@v3 + - name: Publish package distributions to PyPI + run: pdm publish diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..17774bc --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,8 @@ +name: Ruff +on: [pull_request] +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: chartboost/ruff-action@v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a8816c --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm-project.org/#use-with-ide +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f500eb9 --- /dev/null +++ b/README.md @@ -0,0 +1,394 @@ +# Array2image + +***Array2image*** helps you convert Numpy arrays to PIL images. It comes with a single function `array_to_image()`. + +When given an array, it automatically guesses its spatial and channel dimensions. Spatial dimensions greater than 2 are considered as images of images. The resulting image is then represented differently depending on the channel dimension: +* 1D channel: greyscale image. +* 2D channel: image with varying hue and saturation. +* 3D channel: RGB image. + +If specified, custom colormap functions can be used instead. For instance: +* `matplotlib.cm.*` functions for 1D channel arrays (like `matplotlib.cm.viridis`) +* `colormap2d.*` functions for 2D channel arrays (like `colormap2d.pinwheel`) +* The `matplotlib.colors.hsv_to_rgb` function for 3D channel arrays.` + +It assumes that values are floats between 0 and 1 or integers between 0 and 255 (values are clipped anyway). If specified, it automatically normalizes the values. + +Why not directly use `matplotlib.plt.imshow` instead? If you have 2D array with 1 or 3-channel data and don't care about the size nor the incrusted axis in the returned image, `matplotlib.plt.imshow` is great. The ***Array2image*** library makes the focus on simplicity by guessing an appropriate way of rendering non-generic arrays. + +# Installation + +```bash +pip install array2image +``` + +Requires python 3.10+. + +# Documentation + +### Function signature +```python +def array_to_image( + arr, + spatial_dims: tuple[int, ...] | None = None, + channel_dim: int | None = None, + cmap: Callable | None = None, + inverted_colors: bool = False, + bin_size: int | tuple[int, int] | None = None, + target_total_size: int = 200, + grid_thickness: int | tuple[int, ...] = 0, + norm: bool = False, +) -> PIL.Image +``` + +### Argument description + +* **arr**: Array-like to be converted. +* **spatial_dims**: Spatial dimensions of the array. If None, spatial dimensions are +automatically guessed. +* **channel_dim**: Channel dimension of the array. Only 1, 2 or 3 channel dimension +arrays can be converted to an image. If None, the channel dimension is +automatically guessed. +* **cmap**: Colormap function to be used if provided. If None, default built-in +functions are used. +* **inverted_colors**: If True, inverts the color of the image. +* **bin_size**: Number of pixels for each array spatial element. +target_total_size: Target size of the image. Used to automatically choose +`bin_size` if the latter is None. +* **grid_thickness**: Tuple of grid thickness for each level of 2D spatial dimensions. +By default, it is 0 for the last 2D dimensions and 2 pixels for the others. +* **norm**: If True, normalize values between 0 and 1 with a min-max normalization. + +# Examples + +## 1-channel arrays + +Data for the following examples: +```python +import numpy as np + +# Random data: A 2x4x10x8 Numpy array with random values between 0 and 1 +np.random.seed(0) +array = np.random.uniform(0, 1, (2, 4, 10, 8)) + +# MNIST data: The first 48 MNIST digits organized in a 6x8 grid. +mnist_data = ... +array = mnist_data[:48].reshape(6, 8, 28, 28) +``` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+RandomMNIST
+ +```python +from array2image import array_to_image + +# Represent only a 4D array +image = array_to_image(array) +``` + + + + + +
+ +```python +from array2image import array_to_image + +# Force 0 pixel for all grid levels +image = array_to_image( + array, + grid_thickness=(0, 0) +) +``` + + + + + +
+ +```python +from array2image import array_to_image + +# Invert colors +image = array_to_image( + array, + inverted_colors=True +) +``` + + + + + +
+ +```python +from array2image import array_to_image +import matplotlib + +# Use an external colormap +image = array_to_image( + array, + cmap=matplotlib.cm.viridis +) +``` + + + + + +
+ +```python +from array2image import array_to_image +import matplotlib + +# Represent only a 2D array +image = array_to_image( + array[0, 0], + cmap=matplotlib.cm.viridis +) +``` + + + + + +
+ +```python +from array2image import array_to_image +import matplotlib + +# Show a grid +image = array_to_image( + array[0, 0], + cmap=matplotlib.cm.viridis, + grid_thickness=1 +) +``` + + + + + +
+ +```python +from array2image import array_to_image + +# Fix the bin size +image = array_to_image( + array[0, 0], + bin_size=4 +) +``` + + + + + +
+ +```python +from array2image import array_to_image + +# Fix a specific asymetric bin size +image = array_to_image( + array[0, 0], + bin_size=(4,8) +) +``` + + + + + +
+ +## 2-channel arrays + + +Data for the following examples: +```python +import numpy as np + +# Random data: A 10x10x2 Numpy array with random values between 0 and 1 +np.random.seed(0) +array = np.random.uniform(0, 1, (10, 10, 2)) + +# Dummy fourier data: linearly varying phase and magnitude over a 2D grid +phase, amplitude = np.meshgrid(np.linspace(0,1,10), np.meshgrid(np.linspace(0,1,10))) +array = np.stack((phase, amplitude), axis=-1) +``` + + + + + + + + + + + + + + + + + + + +
+RandomFourier
+ +```python +from array2image import array_to_image + +# Default Hue/Saturation colormap +image = array_to_image(array) +``` + + + + + +
+ +```python +from array2image import array_to_image +import colormap2d + +# External 2D colormap +array_to_image( + array, + cmap=colormap2d.pinwheel +) +``` + + + + + +
+ +## 3-channel arrays + +Data for the following examples: +```python +import numpy as np + +# Random data: A 10x10x3 Numpy array with random values between 0 and 1 +np.random.seed(0) +array = np.random.uniform(0, 1, (10, 10, 3)) + +# The Lena RGB image +image = Image.open("lena.png") +array = np.asarray(image) +``` + + + + + + + + + + + + + + + + + + + +
+RandomLena
+ +```python +from array2image import array_to_image + +# Default RGB colormap +image = array_to_image(array) +``` + + + + + +
+ +```python +from array2image import array_to_image +import matplotlib + +# External 3D colormap +array_to_image( + array, + cmap=matplotlib.colors.hsv_to_rgb +) +``` + + + + + +
\ No newline at end of file diff --git a/docs/a2i_2c_fourier.png b/docs/a2i_2c_fourier.png new file mode 100644 index 0000000..7bd7a9a Binary files /dev/null and b/docs/a2i_2c_fourier.png differ diff --git a/docs/a2i_2c_fourier_cmap.png b/docs/a2i_2c_fourier_cmap.png new file mode 100644 index 0000000..0e701af Binary files /dev/null and b/docs/a2i_2c_fourier_cmap.png differ diff --git a/docs/a2i_2c_random.png b/docs/a2i_2c_random.png new file mode 100644 index 0000000..9ada676 Binary files /dev/null and b/docs/a2i_2c_random.png differ diff --git a/docs/a2i_2c_random_cmap.png b/docs/a2i_2c_random_cmap.png new file mode 100644 index 0000000..c3750eb Binary files /dev/null and b/docs/a2i_2c_random_cmap.png differ diff --git a/docs/a2i_3c_lena.png b/docs/a2i_3c_lena.png new file mode 100644 index 0000000..8937a4e Binary files /dev/null and b/docs/a2i_3c_lena.png differ diff --git a/docs/a2i_3c_lena_cmap.png b/docs/a2i_3c_lena_cmap.png new file mode 100644 index 0000000..72cee1d Binary files /dev/null and b/docs/a2i_3c_lena_cmap.png differ diff --git a/docs/a2i_3c_random.png b/docs/a2i_3c_random.png new file mode 100644 index 0000000..7b5f062 Binary files /dev/null and b/docs/a2i_3c_random.png differ diff --git a/docs/a2i_3c_random_cmap.png b/docs/a2i_3c_random_cmap.png new file mode 100644 index 0000000..77d3905 Binary files /dev/null and b/docs/a2i_3c_random_cmap.png differ diff --git a/docs/a2i_mnist_28_28.png b/docs/a2i_mnist_28_28.png new file mode 100644 index 0000000..9850593 Binary files /dev/null and b/docs/a2i_mnist_28_28.png differ diff --git a/docs/a2i_mnist_28_28_grid_1.png b/docs/a2i_mnist_28_28_grid_1.png new file mode 100644 index 0000000..871dcef Binary files /dev/null and b/docs/a2i_mnist_28_28_grid_1.png differ diff --git a/docs/a2i_mnist_28_28_grid_1_bin_4.png b/docs/a2i_mnist_28_28_grid_1_bin_4.png new file mode 100644 index 0000000..a3a97d7 Binary files /dev/null and b/docs/a2i_mnist_28_28_grid_1_bin_4.png differ diff --git a/docs/a2i_mnist_28_28_grid_1_bin_4_8.png b/docs/a2i_mnist_28_28_grid_1_bin_4_8.png new file mode 100644 index 0000000..32b60f1 Binary files /dev/null and b/docs/a2i_mnist_28_28_grid_1_bin_4_8.png differ diff --git a/docs/a2i_mnist_6_8_28_28.png b/docs/a2i_mnist_6_8_28_28.png new file mode 100644 index 0000000..7d8dade Binary files /dev/null and b/docs/a2i_mnist_6_8_28_28.png differ diff --git a/docs/a2i_mnist_6_8_28_28_cmap_viridis.png b/docs/a2i_mnist_6_8_28_28_cmap_viridis.png new file mode 100644 index 0000000..ec357d0 Binary files /dev/null and b/docs/a2i_mnist_6_8_28_28_cmap_viridis.png differ diff --git a/docs/a2i_mnist_6_8_28_28_grid_0_0.png b/docs/a2i_mnist_6_8_28_28_grid_0_0.png new file mode 100644 index 0000000..a5e425b Binary files /dev/null and b/docs/a2i_mnist_6_8_28_28_grid_0_0.png differ diff --git a/docs/a2i_mnist_6_8_28_28_inverted_colors.png b/docs/a2i_mnist_6_8_28_28_inverted_colors.png new file mode 100644 index 0000000..6f8af8f Binary files /dev/null and b/docs/a2i_mnist_6_8_28_28_inverted_colors.png differ diff --git a/docs/a2i_random.png b/docs/a2i_random.png new file mode 100644 index 0000000..001d314 Binary files /dev/null and b/docs/a2i_random.png differ diff --git a/docs/a2i_random_0_0_bin_2.png b/docs/a2i_random_0_0_bin_2.png new file mode 100644 index 0000000..f3f44f2 Binary files /dev/null and b/docs/a2i_random_0_0_bin_2.png differ diff --git a/docs/a2i_random_0_0_bin_2_4.png b/docs/a2i_random_0_0_bin_2_4.png new file mode 100644 index 0000000..2f1ae96 Binary files /dev/null and b/docs/a2i_random_0_0_bin_2_4.png differ diff --git a/docs/a2i_random_0_0_cmap_viridis.png b/docs/a2i_random_0_0_cmap_viridis.png new file mode 100644 index 0000000..7166dfd Binary files /dev/null and b/docs/a2i_random_0_0_cmap_viridis.png differ diff --git a/docs/a2i_random_0_0_cmap_viridis_grid_1.png b/docs/a2i_random_0_0_cmap_viridis_grid_1.png new file mode 100644 index 0000000..c96b3bb Binary files /dev/null and b/docs/a2i_random_0_0_cmap_viridis_grid_1.png differ diff --git a/docs/a2i_random_grid_0_0.png b/docs/a2i_random_grid_0_0.png new file mode 100644 index 0000000..93b3d73 Binary files /dev/null and b/docs/a2i_random_grid_0_0.png differ diff --git a/docs/a2i_random_grid_0_0_cmap_viridis.png b/docs/a2i_random_grid_0_0_cmap_viridis.png new file mode 100644 index 0000000..21f917f Binary files /dev/null and b/docs/a2i_random_grid_0_0_cmap_viridis.png differ diff --git a/docs/a2i_random_grid_0_0_inverted_colors.png b/docs/a2i_random_grid_0_0_inverted_colors.png new file mode 100644 index 0000000..981440c Binary files /dev/null and b/docs/a2i_random_grid_0_0_inverted_colors.png differ diff --git a/pdm.lock b/pdm.lock new file mode 100644 index 0000000..04590b9 --- /dev/null +++ b/pdm.lock @@ -0,0 +1,191 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default", "dev"] +strategy = ["cross_platform"] +lock_version = "4.4" +content_hash = "sha256:c31b206055f8bac2c63347e3c2060109001f8ba2f4c5c5d9fc2b717c8952e29c" + +[[package]] +name = "colorama" +version = "0.4.6" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +summary = "Cross-platform colored terminal text." +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.1.3" +requires_python = ">=3.7" +summary = "Backport of PEP 654 (exception groups)" +files = [ + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +requires_python = ">=3.7" +summary = "brain-dead simple config-ini parsing" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "numpy" +version = "1.26.2" +requires_python = ">=3.9" +summary = "Fundamental package for array computing in Python" +files = [ + {file = "numpy-1.26.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3703fc9258a4a122d17043e57b35e5ef1c5a5837c3db8be396c82e04c1cf9b0f"}, + {file = "numpy-1.26.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc392fdcbd21d4be6ae1bb4475a03ce3b025cd49a9be5345d76d7585aea69440"}, + {file = "numpy-1.26.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36340109af8da8805d8851ef1d74761b3b88e81a9bd80b290bbfed61bd2b4f75"}, + {file = "numpy-1.26.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcc008217145b3d77abd3e4d5ef586e3bdfba8fe17940769f8aa09b99e856c00"}, + {file = "numpy-1.26.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3ced40d4e9e18242f70dd02d739e44698df3dcb010d31f495ff00a31ef6014fe"}, + {file = "numpy-1.26.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b272d4cecc32c9e19911891446b72e986157e6a1809b7b56518b4f3755267523"}, + {file = "numpy-1.26.2-cp310-cp310-win32.whl", hash = "sha256:22f8fc02fdbc829e7a8c578dd8d2e15a9074b630d4da29cda483337e300e3ee9"}, + {file = "numpy-1.26.2-cp310-cp310-win_amd64.whl", hash = "sha256:26c9d33f8e8b846d5a65dd068c14e04018d05533b348d9eaeef6c1bd787f9919"}, + {file = "numpy-1.26.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b96e7b9c624ef3ae2ae0e04fa9b460f6b9f17ad8b4bec6d7756510f1f6c0c841"}, + {file = "numpy-1.26.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aa18428111fb9a591d7a9cc1b48150097ba6a7e8299fb56bdf574df650e7d1f1"}, + {file = "numpy-1.26.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06fa1ed84aa60ea6ef9f91ba57b5ed963c3729534e6e54055fc151fad0423f0a"}, + {file = "numpy-1.26.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96ca5482c3dbdd051bcd1fce8034603d6ebfc125a7bd59f55b40d8f5d246832b"}, + {file = "numpy-1.26.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:854ab91a2906ef29dc3925a064fcd365c7b4da743f84b123002f6139bcb3f8a7"}, + {file = "numpy-1.26.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f43740ab089277d403aa07567be138fc2a89d4d9892d113b76153e0e412409f8"}, + {file = "numpy-1.26.2-cp311-cp311-win32.whl", hash = "sha256:a2bbc29fcb1771cd7b7425f98b05307776a6baf43035d3b80c4b0f29e9545186"}, + {file = "numpy-1.26.2-cp311-cp311-win_amd64.whl", hash = "sha256:2b3fca8a5b00184828d12b073af4d0fc5fdd94b1632c2477526f6bd7842d700d"}, + {file = "numpy-1.26.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a4cd6ed4a339c21f1d1b0fdf13426cb3b284555c27ac2f156dfdaaa7e16bfab0"}, + {file = "numpy-1.26.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d5244aabd6ed7f312268b9247be47343a654ebea52a60f002dc70c769048e75"}, + {file = "numpy-1.26.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a3cdb4d9c70e6b8c0814239ead47da00934666f668426fc6e94cce869e13fd7"}, + {file = "numpy-1.26.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa317b2325f7aa0a9471663e6093c210cb2ae9c0ad824732b307d2c51983d5b6"}, + {file = "numpy-1.26.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:174a8880739c16c925799c018f3f55b8130c1f7c8e75ab0a6fa9d41cab092fd6"}, + {file = "numpy-1.26.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f79b231bf5c16b1f39c7f4875e1ded36abee1591e98742b05d8a0fb55d8a3eec"}, + {file = "numpy-1.26.2-cp312-cp312-win32.whl", hash = "sha256:4a06263321dfd3598cacb252f51e521a8cb4b6df471bb12a7ee5cbab20ea9167"}, + {file = "numpy-1.26.2-cp312-cp312-win_amd64.whl", hash = "sha256:b04f5dc6b3efdaab541f7857351aac359e6ae3c126e2edb376929bd3b7f92d7e"}, + {file = "numpy-1.26.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1cc3d5029a30fb5f06704ad6b23b35e11309491c999838c31f124fee32107c79"}, + {file = "numpy-1.26.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94cc3c222bb9fb5a12e334d0479b97bb2df446fbe622b470928f5284ffca3f8d"}, + {file = "numpy-1.26.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fe6b44fb8fcdf7eda4ef4461b97b3f63c466b27ab151bec2366db8b197387841"}, + {file = "numpy-1.26.2.tar.gz", hash = "sha256:f65738447676ab5777f11e6bbbdb8ce11b785e105f690bc45966574816b6d3ea"}, +] + +[[package]] +name = "packaging" +version = "23.2" +requires_python = ">=3.7" +summary = "Core utilities for Python packages" +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pillow" +version = "10.1.0" +requires_python = ">=3.8" +summary = "Python Imaging Library (Fork)" +files = [ + {file = "Pillow-10.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1ab05f3db77e98f93964697c8efc49c7954b08dd61cff526b7f2531a22410106"}, + {file = "Pillow-10.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6932a7652464746fcb484f7fc3618e6503d2066d853f68a4bd97193a3996e273"}, + {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f63b5a68daedc54c7c3464508d8c12075e56dcfbd42f8c1bf40169061ae666"}, + {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0949b55eb607898e28eaccb525ab104b2d86542a85c74baf3a6dc24002edec2"}, + {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ae88931f93214777c7a3aa0a8f92a683f83ecde27f65a45f95f22d289a69e593"}, + {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b0eb01ca85b2361b09480784a7931fc648ed8b7836f01fb9241141b968feb1db"}, + {file = "Pillow-10.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d27b5997bdd2eb9fb199982bb7eb6164db0426904020dc38c10203187ae2ff2f"}, + {file = "Pillow-10.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7df5608bc38bd37ef585ae9c38c9cd46d7c81498f086915b0f97255ea60c2818"}, + {file = "Pillow-10.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:41f67248d92a5e0a2076d3517d8d4b1e41a97e2df10eb8f93106c89107f38b57"}, + {file = "Pillow-10.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1fb29c07478e6c06a46b867e43b0bcdb241b44cc52be9bc25ce5944eed4648e7"}, + {file = "Pillow-10.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2cdc65a46e74514ce742c2013cd4a2d12e8553e3a2563c64879f7c7e4d28bce7"}, + {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50d08cd0a2ecd2a8657bd3d82c71efd5a58edb04d9308185d66c3a5a5bed9610"}, + {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:062a1610e3bc258bff2328ec43f34244fcec972ee0717200cb1425214fe5b839"}, + {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:61f1a9d247317fa08a308daaa8ee7b3f760ab1809ca2da14ecc88ae4257d6172"}, + {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a646e48de237d860c36e0db37ecaecaa3619e6f3e9d5319e527ccbc8151df061"}, + {file = "Pillow-10.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:47e5bf85b80abc03be7455c95b6d6e4896a62f6541c1f2ce77a7d2bb832af262"}, + {file = "Pillow-10.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a92386125e9ee90381c3369f57a2a50fa9e6aa8b1cf1d9c4b200d41a7dd8e992"}, + {file = "Pillow-10.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:0f7c276c05a9767e877a0b4c5050c8bee6a6d960d7f0c11ebda6b99746068c2a"}, + {file = "Pillow-10.1.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:a89b8312d51715b510a4fe9fc13686283f376cfd5abca8cd1c65e4c76e21081b"}, + {file = "Pillow-10.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:00f438bb841382b15d7deb9a05cc946ee0f2c352653c7aa659e75e592f6fa17d"}, + {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d929a19f5469b3f4df33a3df2983db070ebb2088a1e145e18facbc28cae5b27"}, + {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a92109192b360634a4489c0c756364c0c3a2992906752165ecb50544c251312"}, + {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0248f86b3ea061e67817c47ecbe82c23f9dd5d5226200eb9090b3873d3ca32de"}, + {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9882a7451c680c12f232a422730f986a1fcd808da0fd428f08b671237237d651"}, + {file = "Pillow-10.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1c3ac5423c8c1da5928aa12c6e258921956757d976405e9467c5f39d1d577a4b"}, + {file = "Pillow-10.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:806abdd8249ba3953c33742506fe414880bad78ac25cc9a9b1c6ae97bedd573f"}, + {file = "Pillow-10.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:eaed6977fa73408b7b8a24e8b14e59e1668cfc0f4c40193ea7ced8e210adf996"}, + {file = "Pillow-10.1.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:937bdc5a7f5343d1c97dc98149a0be7eb9704e937fe3dc7140e229ae4fc572a7"}, + {file = "Pillow-10.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c25762197144e211efb5f4e8ad656f36c8d214d390585d1d21281f46d556ba"}, + {file = "Pillow-10.1.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:afc8eef765d948543a4775f00b7b8c079b3321d6b675dde0d02afa2ee23000b4"}, + {file = "Pillow-10.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:883f216eac8712b83a63f41b76ddfb7b2afab1b74abbb413c5df6680f071a6b9"}, + {file = "Pillow-10.1.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b920e4d028f6442bea9a75b7491c063f0b9a3972520731ed26c83e254302eb1e"}, + {file = "Pillow-10.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c41d960babf951e01a49c9746f92c5a7e0d939d1652d7ba30f6b3090f27e412"}, + {file = "Pillow-10.1.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1fafabe50a6977ac70dfe829b2d5735fd54e190ab55259ec8aea4aaea412fa0b"}, + {file = "Pillow-10.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3b834f4b16173e5b92ab6566f0473bfb09f939ba14b23b8da1f54fa63e4b623f"}, + {file = "Pillow-10.1.0.tar.gz", hash = "sha256:e6bf8de6c36ed96c86ea3b6e1d5273c53f46ef518a062464cd7ef5dd2cf92e38"}, +] + +[[package]] +name = "pluggy" +version = "1.3.0" +requires_python = ">=3.8" +summary = "plugin and hook calling mechanisms for python" +files = [ + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, +] + +[[package]] +name = "pytest" +version = "7.4.3" +requires_python = ">=3.7" +summary = "pytest: simple powerful testing with Python" +dependencies = [ + "colorama; sys_platform == \"win32\"", + "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", + "iniconfig", + "packaging", + "pluggy<2.0,>=0.12", + "tomli>=1.0.0; python_version < \"3.11\"", +] +files = [ + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, +] + +[[package]] +name = "ruff" +version = "0.1.5" +requires_python = ">=3.7" +summary = "An extremely fast Python linter and code formatter, written in Rust." +files = [ + {file = "ruff-0.1.5-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:32d47fc69261c21a4c48916f16ca272bf2f273eb635d91c65d5cd548bf1f3d96"}, + {file = "ruff-0.1.5-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:171276c1df6c07fa0597fb946139ced1c2978f4f0b8254f201281729981f3c17"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17ef33cd0bb7316ca65649fc748acc1406dfa4da96a3d0cde6d52f2e866c7b39"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b2c205827b3f8c13b4a432e9585750b93fd907986fe1aec62b2a02cf4401eee6"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb408e3a2ad8f6881d0f2e7ad70cddb3ed9f200eb3517a91a245bbe27101d379"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f20dc5e5905ddb407060ca27267c7174f532375c08076d1a953cf7bb016f5a24"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aafb9d2b671ed934998e881e2c0f5845a4295e84e719359c71c39a5363cccc91"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a4894dddb476597a0ba4473d72a23151b8b3b0b5f958f2cf4d3f1c572cdb7af7"}, + {file = "ruff-0.1.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00a7ec893f665ed60008c70fe9eeb58d210e6b4d83ec6654a9904871f982a2a"}, + {file = "ruff-0.1.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8c11206b47f283cbda399a654fd0178d7a389e631f19f51da15cbe631480c5b"}, + {file = "ruff-0.1.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fa29e67b3284b9a79b1a85ee66e293a94ac6b7bb068b307a8a373c3d343aa8ec"}, + {file = "ruff-0.1.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9b97fd6da44d6cceb188147b68db69a5741fbc736465b5cea3928fdac0bc1aeb"}, + {file = "ruff-0.1.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:721f4b9d3b4161df8dc9f09aa8562e39d14e55a4dbaa451a8e55bdc9590e20f4"}, + {file = "ruff-0.1.5-py3-none-win32.whl", hash = "sha256:f80c73bba6bc69e4fdc73b3991db0b546ce641bdcd5b07210b8ad6f64c79f1ab"}, + {file = "ruff-0.1.5-py3-none-win_amd64.whl", hash = "sha256:c21fe20ee7d76206d290a76271c1af7a5096bc4c73ab9383ed2ad35f852a0087"}, + {file = "ruff-0.1.5-py3-none-win_arm64.whl", hash = "sha256:82bfcb9927e88c1ed50f49ac6c9728dab3ea451212693fe40d08d314663e412f"}, + {file = "ruff-0.1.5.tar.gz", hash = "sha256:5cbec0ef2ae1748fb194f420fb03fb2c25c3258c86129af7172ff8f198f125ab"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +requires_python = ">=3.7" +summary = "A lil' TOML parser" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ca2820d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[project] +name = "array2image" +version = "0.1.0" +description = "Converts a Numpy array to a PIL image." +authors = [ + {name = "Matthieu Thiboust", email = "14574229+mthiboust@users.noreply.github.com"}, +] +dependencies = [ + "numpy>=1.26.2", + "pillow>=8.4.0", +] +requires-python = ">=3.10" +readme = "README.md" +license = {text = "Apache 2.0"} + +dev = [ + "pytest>=7.4.3", + "ruff>=0.1.5", +] + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" + +[tool.ruff] +line-length = 88 +#extend-include = [ +# "*.ipynb", +#] +select = [ + "D", + "E", + "F", + "I001", +] +ignore = [ + "D206", + "F722", +] +ignore-init-module-imports = true +fixable = [ + "I001", + "F401", +] + +[tool.ruff.isort] +combine-as-imports = true +lines-after-imports = 2 +order-by-type = false + +[tool.ruff.pydocstyle] +convention = "google" +[tool.pdm.dev-dependencies] +dev = [ + "pytest>=7.4.3", + "ruff>=0.1.5", +] diff --git a/src/array2image/__init__.py b/src/array2image/__init__.py new file mode 100644 index 0000000..92437ef --- /dev/null +++ b/src/array2image/__init__.py @@ -0,0 +1,6 @@ +"""Import the only public function.""" + +from .core import array_to_image + + +__all__ = ["array_to_image"] diff --git a/src/array2image/core.py b/src/array2image/core.py new file mode 100644 index 0000000..d93fe5c --- /dev/null +++ b/src/array2image/core.py @@ -0,0 +1,261 @@ +"""Array2image.""" + +import logging +from collections.abc import Callable + +import numpy as np +from PIL import Image + + +def _ensure_tuple(x): + if isinstance(x, tuple) and len(x) == 2: + return x + elif isinstance(x, int): + return (x, x) + else: + raise ValueError( + f"Type of {x} should be either `int` or `tuple[int, int]`, not {type(x)}." + ) + + +def _add_grid( + arr: np.ndarray, + spacing: int | tuple[int, int], + thickness: int | tuple[int, int] = 1, + color: float = 1.0, +) -> np.ndarray: + """Adds a grid to an array. + + The spacing parameter should be a divisor of the array spatial dimensions. + + Args: + arr: Numpy array whose last 3 dimensions are spatial (x, y) and channel (c). + spacing: Spacing to be applied. Must be a divisor of the array spatial dims. + thickness: Thickness of the grid line. + color: Color of the grid line. White is 1, black is 0. + + Returns: + An array with modified spatial dimensions. + """ + *s, x, y, c = arr.shape + sx, sy = _ensure_tuple(spacing) + tx, ty = _ensure_tuple(thickness) + + if x % sx != 0 or y % sy != 0: + raise ValueError(f"{x} (resp {y}) should be divisible by {sx} (resp {sy}).") + + arr = arr.reshape(tuple(s) + (x // sx, sx, y // sy, sy, c)) + + # Add the internal grid, including the trailing vertical and horizontal lines. + pad_width = [(0, 0) for i in range(arr.ndim)] + pad_width[-4] = (0, tx) + pad_width[-2] = (0, ty) + pad_width = tuple(pad_width) + arr = np.pad(arr, pad_width, mode="constant", constant_values=color) + + arr = arr.reshape(tuple(s) + ((x // sx) * (sx + tx), (y // sy) * (sy + ty), c)) + + # Add the missing leading vertical and horizontal lines. + pad_width = [(0, 0) for i in range(arr.ndim)] + pad_width[-3] = (tx, 0) + pad_width[-2] = (ty, 0) + pad_width = tuple(pad_width) + arr = np.pad(arr, pad_width, mode="constant", constant_values=color) + + return arr + + +def _guess_spatial_channel_dims(shape: tuple[int, ...]) -> tuple[tuple[int], int]: + """Guesses the spatial and channel dimensions of an array shape. + + Example: + ```python + _guess_spatial_channel_dims((1,)) # ((1,), 1) + _guess_spatial_channel_dims((8,)) # ((8,), 1) + _guess_spatial_channel_dims((8,8)) # ((8, 8), 1) + _guess_spatial_channel_dims((8,8,1)) # ((8, 8), 1) + _guess_spatial_channel_dims((8,8,3)) # ((8, 8), 3) + _guess_spatial_channel_dims((8,8,4)) # ((8, 8, 4), 1) + + # A 3x3 array can be ambiguous because it could mean either: + # * a list of 3 RGB values (default guess) + # * a 3x3 greyscale image (need to reshape to (3,3,1) for this behavior) + _guess_spatial_channel_dims((3,3)) # ((3,), 3) + _guess_spatial_channel_dims((3,3,1)) # ((3, 3), 1) + ``` + + Args: + shape: Shape of the array. + + Returns: + A tuple of the spatial dimensions (tuple) and the channel dimension (int). + """ + match shape: + case (*s, c) if c <= 3 and len(s) > 0: + spatial_dims, channel_dim = tuple(s), c + case _: + spatial_dims, channel_dim = shape, 1 + return spatial_dims, channel_dim + + +def array_to_image( + arr, + spatial_dims: tuple[int, ...] | None = None, + channel_dim: int | None = None, + cmap: Callable | None = None, + inverted_colors: bool = False, + bin_size: int | tuple[int, int] | None = None, + target_total_size: int = 200, + grid_thickness: int | tuple[int, ...] = 0, + norm: bool = False, +) -> Image: + """Converts an array-like to a PIL image. + + Visualization function to get a quick overview of a 2D/4D/nD array containing a + 1D/2D/3D channel. + + When given an array, it automatically guesses its spatial and channel dimensions. + Spatial dimensions greater than 2 are considered as images of images. The resulting + image is then represented differently depending on the channel dimension: + * 1D channel: greyscale image. + * 2D channel: image with varying hue and saturation. + * 3D channel: RGB image. + + If specified, custom colormap functions can be used instead. For instance: + * `matplotlib.cm.*` functions for 1D channel arrays (like `matplotlib.cm.viridis`) + * `colormap2d.*` functions for 2D channel arrays (like `colormap2d.pinwheel`) + * The `matplotlib.colors.hsv_to_rgb` function for 3D channel arrays.` + + It assumes that values are floats between 0 and 1 or integers between 0 and 255 + (values are clipped anyway). If specified, it automatically normalizes the values. + + Args: + arr: Array-like to be converted. + spatial_dims: Spatial dimensions of the array. If None, spatial dimensions are + automatically guessed. + channel_dim: Channel dimension of the array. Only 1, 2 or 3 channel dimension + arrays can be converted to an image. If None, the channel dimension is + automatically guessed. + cmap: Colormap function to be used if provided. If None, default built-in + functions are used. + inverted_colors: If True, inverts the color of the image. + bin_size: Number of pixels for each array spatial element. + target_total_size: Target size of the image. Used to automatically choose + `bin_size` if the latter is None. + grid_thickness: Tuple of grid thickness for each level of 2D spatial dimensions. + By default, it is 0 for the last 2D dimensions and 2 pixels for the others. + norm: If True, normalize values between 0 and 1 with a min-max normalization. + """ + arr = np.asarray(arr) + + if np.issubdtype(arr.dtype, np.integer): + arr = arr / 255 + + if not np.issubdtype(arr.dtype, np.floating): + raise TypeError( + "The array values should be either floats between 0 and 1, or integers " + "between 0 and 255." + ) + + if norm: + min_val = arr.min() + max_val = arr.max() + arr = (arr - min_val) / (max_val - min_val) + + elif arr.min() < 0 or arr.max() > 1: + logging.warning( + "Clipping values not in the [0:1] range. " + "You may want to use the `norm=True` argument." + ) + arr = np.clip(arr, 0.0, 1.0) + + if inverted_colors: + arr = 1 - arr + + # Try to guess the spatial and channel dim to represent the data + if spatial_dims is None or channel_dim is None: + spatial_dims, channel_dim = _guess_spatial_channel_dims(arr.shape) + + # Make sure that the number of spatial dims is a multiple of 2 + if len(spatial_dims) % 2 == 1: + spatial_dims = (1,) + spatial_dims + + if channel_dim > 3: + raise ValueError( + f"Cannot represent `channel_dim` of {channel_dim}." + f"Possible values: 1, 2 or 3" + ) + + # Force a 3D array with 2 spatial dimensions and 1 channel dimension + arr = arr.reshape(spatial_dims + (channel_dim,)) + # assert len(arr.shape) == 3 + + # print(f"before colormap: {arr.shape=}") + if cmap is not None: + _shape = arr.shape + if channel_dim == 1: + # Matplotlib colormap functions need no channel dimension + arr = arr.squeeze(axis=-1) + + # Apply colormap and make sure to only keep 3 channels + # (matplotlib colormap functions returns 4 channels RGBA). + arr = cmap(arr) + + if len(arr.shape) > len(_shape): + raise ValueError( + "The colormap function has changed the number of dimensions " + f"of the array from {_shape} to {arr.shape}. " + "Make sure this colormap function is adapted to array with a " + f"channel dimension of {channel_dim}." + ) + + elif cmap is None and channel_dim == 2: + # Add a third channel filled with 1s and consider those values as HSV values. + arr = np.concatenate((arr, np.ones(spatial_dims + (1,))), axis=-1) + + if bin_size is None: + # Try to guess a convenient scale_factor + x_charac = int(np.prod(spatial_dims[::2])) + y_charac = int(np.prod(spatial_dims[1::2])) + bin_size = max(1, target_total_size // max(x_charac, y_charac)) + + bin_size = _ensure_tuple(bin_size) + + if bin_size != (1, 1): + # Rescale each value to a bin of bin_size[0]*bin_size[1] pixels. + *s, x, y, c = arr.shape + arr = arr.reshape(tuple(s) + (x, 1, y, 1, c)) + arr = arr * np.ones(tuple(s) + (x, bin_size[0], y, bin_size[1], c)) + arr = arr.reshape(tuple(s) + (x * bin_size[0], y * bin_size[1], c)) + + # Set default grid thicknesses for all levels if not provided + grid_thickness = ( + [grid_thickness] if isinstance(grid_thickness, int) else list(grid_thickness) + ) + + while len(grid_thickness) < len(spatial_dims) // 2: + grid_thickness.insert(0, max(2, grid_thickness[0])) + + if (thickness := grid_thickness.pop()) != 0: + arr = _add_grid(arr, bin_size, thickness) + + # If array has more than 3 spatial dimensions, iterate to make images of images + while len(arr.shape) >= 5: + *s, xx, yy, x, y, c = arr.shape + + arr = arr.swapaxes(-4, -3) + arr = arr.reshape(tuple(s) + (xx * x, yy * y, c)) + + if (thickness := grid_thickness.pop()) != 0: + arr = _add_grid(arr, (x, y), thickness) + + # Return the corresponding PIL image + match arr.shape[-1]: + case 1: + return Image.fromarray(np.uint8(arr.squeeze(axis=-1) * 255), "L") + case 3: + return Image.fromarray(np.uint8(arr * 255), "RGB") + case 4: + return Image.fromarray(np.uint8(arr * 255), "RGBA") + case _: + raise ValueError(f"{arr.shape=}") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d420712 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests.""" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..cb75b1a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,36 @@ +"""Tests the plotting utility functions. + +Todo: +* array_to_image tests +* add_grid tests +""" + +# ruff: noqa: D103 + +import numpy as np +from array2image.core import _add_grid, _guess_spatial_channel_dims, array_to_image + + +def test_guess_spatial_channel_dimensions(): + assert _guess_spatial_channel_dims((1,)) == ((1,), 1) + assert _guess_spatial_channel_dims((8,)) == ((8,), 1) + assert _guess_spatial_channel_dims((8, 8)) == ((8, 8), 1) + assert _guess_spatial_channel_dims((8, 8, 1)) == ((8, 8), 1) + assert _guess_spatial_channel_dims((8, 8, 3)) == ((8, 8), 3) + assert _guess_spatial_channel_dims((8, 8, 4)) == ( + (8, 8, 4), + 1, + ) + + assert _guess_spatial_channel_dims((3, 3)) == ((3,), 3) + assert _guess_spatial_channel_dims((3, 3, 1)) == ((3, 3), 1) + + +def test_array_to_image(): + array = np.ones((10, 10, 1)) + array_to_image(array) + + +def test_add_grid(): + array = np.ones((10, 10, 1)) + _add_grid(array, spacing=1, thickness=1)