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)
+```
+
+
+
+
+ |
+Random |
+MNIST |
+
+
+
+
+```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)
+```
+
+
+
+
+ |
+Random |
+Fourier |
+
+
+
+
+```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)
+```
+
+
+
+
+ |
+Random |
+Lena |
+
+
+
+
+```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)