├── .bumpversion.cfg ├── .cookiecutterrc ├── .coveragerc ├── .editorconfig ├── .github └── workflows │ ├── deploy.yml │ └── github-actions.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.rst ├── ci ├── bootstrap.py ├── requirements.txt └── templates │ └── .github │ └── workflows │ └── github-actions.yml ├── docs ├── authors.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── index.rst ├── installation.rst ├── readme.rst ├── reference │ ├── img2cmap.rst │ └── index.rst ├── requirements.txt ├── spelling_wordlist.txt └── usage.rst ├── fly.toml ├── images ├── colorbar.png ├── img2cmap_demo.png ├── lakers.png ├── lakers_no_transparent.png ├── lakers_with_transparent.png └── webapp_image.png ├── pyproject.toml ├── pytest.ini ├── setup.cfg ├── setup.py ├── src └── img2cmap │ ├── __init__.py │ └── convert.py ├── streamlit └── app.py ├── tests ├── images │ ├── black_square.jpg │ ├── colorful_city.png │ ├── movie_chart.png │ └── south_beach_sunset.jpg ├── test_img2cmap.py └── urls │ └── nba-logos.txt └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.2.3 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version="{current_version}" 8 | replace = version="{new_version}" 9 | 10 | [bumpversion:file:docs/conf.py] 11 | search = version = release = "{current_version}" 12 | replace = version = release = "{new_version}" 13 | 14 | [bumpversion:file:src/img2cmap/__init__.py] 15 | search = __version__ = "{current_version}" 16 | replace = __version__ = "{new_version}" 17 | -------------------------------------------------------------------------------- /.cookiecutterrc: -------------------------------------------------------------------------------- 1 | # This file exists so you can easily regenerate your project. 2 | # 3 | # `cookiepatcher` is a convenient shim around `cookiecutter` 4 | # for regenerating projects (it will generate a .cookiecutterrc 5 | # automatically for any template). To use it: 6 | # 7 | # pip install cookiepatcher 8 | # cookiepatcher gh:ionelmc/cookiecutter-pylibrary img2cmap 9 | # 10 | # See: 11 | # https://pypi.org/project/cookiepatcher 12 | # 13 | # Alternatively, you can run: 14 | # 15 | # cookiecutter --overwrite-if-exists --config-file=img2cmap/.cookiecutterrc gh:ionelmc/cookiecutter-pylibrary 16 | 17 | default_context: 18 | 19 | _extensions: ['jinja2_time.TimeExtension'] 20 | _template: 'gh:ionelmc/cookiecutter-pylibrary' 21 | allow_tests_inside_package: 'no' 22 | appveyor: 'no' 23 | c_extension_function: 'longest' 24 | c_extension_module: '_img2cmap' 25 | c_extension_optional: 'no' 26 | c_extension_support: 'no' 27 | c_extension_test_pypi: 'no' 28 | c_extension_test_pypi_username: 'arvkevi' 29 | codacy: 'no' 30 | codacy_projectid: '[Get ID from https://app.codacy.com/gh/arvkevi/img2cmap/settings]' 31 | codeclimate: 'no' 32 | codecov: 'yes' 33 | command_line_interface: 'no' 34 | command_line_interface_bin_name: 'img2cmap' 35 | coveralls: 'no' 36 | distribution_name: 'img2cmap' 37 | email: 'arvkevi@gmail.com' 38 | full_name: 'Kevin Arvai' 39 | github_actions: 'yes' 40 | github_actions_osx: 'yes' 41 | github_actions_windows: 'yes' 42 | legacy_python: 'no' 43 | license: 'MIT license' 44 | linter: 'flake8' 45 | package_name: 'img2cmap' 46 | pre_commit: 'yes' 47 | pre_commit_formatter: 'black' 48 | project_name: 'img2cmap' 49 | project_short_description: 'Create colormaps from images' 50 | pypi_badge: 'yes' 51 | pypi_disable_upload: 'no' 52 | release_date: 'today' 53 | repo_hosting: 'github.com' 54 | repo_hosting_domain: 'github.com' 55 | repo_main_branch: 'main' 56 | repo_name: 'img2cmap' 57 | repo_username: 'arvkevi' 58 | requiresio: 'yes' 59 | scrutinizer: 'no' 60 | setup_py_uses_pytest_runner: 'no' 61 | setup_py_uses_setuptools_scm: 'no' 62 | sphinx_docs: 'yes' 63 | sphinx_docs_hosting: 'https://img2cmap.readthedocs.io/' 64 | sphinx_doctest: 'no' 65 | sphinx_theme: 'sphinx-rtd-theme' 66 | test_matrix_configurator: 'no' 67 | test_matrix_separate_coverage: 'no' 68 | travis: 'no' 69 | travis_osx: 'no' 70 | version: '0.0.0' 71 | version_manager: 'bump2version' 72 | website: 'https://github.com/arvkevi' 73 | year_from: '2022' 74 | year_to: '2022' 75 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | src 4 | */site-packages 5 | 6 | [run] 7 | branch = true 8 | source = 9 | img2cmap 10 | tests 11 | parallel = true 12 | 13 | [report] 14 | show_missing = true 15 | precision = 2 16 | omit = *migrations* 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see https://editorconfig.org/ 2 | root = true 3 | 4 | [*] 5 | # Use Unix-style newlines for most files (except Windows files, see below). 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | insert_final_newline = true 10 | indent_size = 4 11 | charset = utf-8 12 | 13 | [*.{bat,cmd,ps1}] 14 | end_of_line = crlf 15 | 16 | [*.{yml,yaml}] 17 | indent_size = 2 18 | 19 | [*.tsv] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Fly Deploy 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | env: 7 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 8 | jobs: 9 | deploy: 10 | name: Deploy app 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: superfly/flyctl-actions/setup-flyctl@master 15 | - run: flyctl deploy --remote-only 16 | -------------------------------------------------------------------------------- /.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: ${{ matrix.name }} 6 | runs-on: ${{ matrix.os }} 7 | timeout-minutes: 30 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - name: 'check' 13 | python: '3.9' 14 | toxpython: 'python3.9' 15 | tox_env: 'check' 16 | os: 'ubuntu-latest' 17 | - name: 'docs' 18 | python: '3.9' 19 | toxpython: 'python3.9' 20 | tox_env: 'docs' 21 | os: 'ubuntu-latest' 22 | - name: 'py37 (ubuntu)' 23 | python: '3.7' 24 | toxpython: 'python3.7' 25 | python_arch: 'x64' 26 | tox_env: 'py37' 27 | os: 'ubuntu-latest' 28 | - name: 'py37 (windows)' 29 | python: '3.7' 30 | toxpython: 'python3.7' 31 | python_arch: 'x64' 32 | tox_env: 'py37' 33 | os: 'windows-latest' 34 | - name: 'py37 (macos)' 35 | python: '3.7' 36 | toxpython: 'python3.7' 37 | python_arch: 'x64' 38 | tox_env: 'py37' 39 | os: 'macos-latest' 40 | - name: 'py38 (ubuntu)' 41 | python: '3.8' 42 | toxpython: 'python3.8' 43 | python_arch: 'x64' 44 | tox_env: 'py38' 45 | os: 'ubuntu-latest' 46 | - name: 'py38 (windows)' 47 | python: '3.8' 48 | toxpython: 'python3.8' 49 | python_arch: 'x64' 50 | tox_env: 'py38' 51 | os: 'windows-latest' 52 | - name: 'py38 (macos)' 53 | python: '3.8' 54 | toxpython: 'python3.8' 55 | python_arch: 'x64' 56 | tox_env: 'py38' 57 | os: 'macos-latest' 58 | - name: 'py39 (ubuntu)' 59 | python: '3.9' 60 | toxpython: 'python3.9' 61 | python_arch: 'x64' 62 | tox_env: 'py39' 63 | os: 'ubuntu-latest' 64 | - name: 'py39 (windows)' 65 | python: '3.9' 66 | toxpython: 'python3.9' 67 | python_arch: 'x64' 68 | tox_env: 'py39' 69 | os: 'windows-latest' 70 | - name: 'py39 (macos)' 71 | python: '3.9' 72 | toxpython: 'python3.9' 73 | python_arch: 'x64' 74 | tox_env: 'py39' 75 | os: 'macos-latest' 76 | - name: 'py310 (ubuntu)' 77 | python: '3.10' 78 | toxpython: 'python3.10' 79 | python_arch: 'x64' 80 | tox_env: 'py310' 81 | os: 'ubuntu-latest' 82 | - name: 'py310 (windows)' 83 | python: '3.10' 84 | toxpython: 'python3.10' 85 | python_arch: 'x64' 86 | tox_env: 'py310' 87 | os: 'windows-latest' 88 | - name: 'py310 (macos)' 89 | python: '3.10' 90 | toxpython: 'python3.10' 91 | python_arch: 'x64' 92 | tox_env: 'py310' 93 | os: 'macos-latest' 94 | steps: 95 | - uses: actions/checkout@v2 96 | with: 97 | fetch-depth: 0 98 | - uses: actions/setup-python@v2 99 | with: 100 | python-version: ${{ matrix.python }} 101 | architecture: ${{ matrix.python_arch }} 102 | - name: install dependencies 103 | run: | 104 | python -mpip install --progress-bar=off -r ci/requirements.txt 105 | virtualenv --version 106 | pip --version 107 | tox --version 108 | pip list --format=freeze 109 | - name: test 110 | env: 111 | TOXPYTHON: '${{ matrix.toxpython }}' 112 | run: > 113 | tox -e ${{ matrix.tox_env }} -v 114 | - name: Generate coverage report 115 | run: | 116 | pip install -e .[dev] 117 | pip install pytest 118 | pip install pytest-cov 119 | pytest --cov=./ --cov-report=xml 120 | - name: Codecov 121 | uses: codecov/codecov-action@v3.1.1 122 | with: 123 | fail_ci_if_error: true 124 | flags: unittests 125 | name: codecov-umbrella 126 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | .eggs 14 | parts 15 | bin 16 | var 17 | sdist 18 | wheelhouse 19 | develop-eggs 20 | .installed.cfg 21 | lib 22 | lib64 23 | venv*/ 24 | pyvenv*/ 25 | pip-wheel-metadata/ 26 | 27 | # Installer logs 28 | pip-log.txt 29 | 30 | # Unit test / coverage reports 31 | .coverage 32 | .tox 33 | .coverage.* 34 | .pytest_cache/ 35 | nosetests.xml 36 | coverage.xml 37 | htmlcov 38 | 39 | # Translations 40 | *.mo 41 | 42 | # Buildout 43 | .mr.developer.cfg 44 | 45 | # IDE project files 46 | .project 47 | .pydevproject 48 | .idea 49 | .vscode 50 | *.iml 51 | *.komodoproject 52 | 53 | # Complexity 54 | output/*.html 55 | output/*/index.html 56 | 57 | # Sphinx 58 | docs/_build 59 | 60 | .DS_Store 61 | *~ 62 | .*.sw[po] 63 | .build 64 | .ve 65 | .env 66 | .cache 67 | .pytest 68 | .benchmarks 69 | .bootstrap 70 | .appveyor.token 71 | *.bak 72 | 73 | # Mypy Cache 74 | .mypy_cache/ 75 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # To install the git pre-commit hook run: 2 | # pre-commit install 3 | # To update the pre-commit hooks run: 4 | # pre-commit install-hooks 5 | exclude: '^(\.tox|ci/templates|\.bumpversion\.cfg)(/|$)' 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.2.0 9 | hooks: 10 | - id: trailing-whitespace 11 | - id: end-of-file-fixer 12 | - id: debug-statements 13 | - repo: https://github.com/timothycrosley/isort 14 | rev: 5.13.2 15 | hooks: 16 | - id: isort 17 | - repo: https://github.com/psf/black 18 | rev: 23.12.1 19 | hooks: 20 | - id: black 21 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | version: 2 3 | sphinx: 4 | configuration: docs/conf.py 5 | formats: all 6 | python: 7 | install: 8 | - requirements: docs/requirements.txt 9 | - method: pip 10 | path: . 11 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | 2 | Authors 3 | ======= 4 | 5 | * Kevin Arvai - https://github.com/arvkevi 6 | * Marshall Krassenstein - https://github.com/mpkrass7 7 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | ========= 4 | 5 | 0.0.0 (2022-04-30) 6 | ------------------ 7 | 8 | * First release on PyPI. 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Bug reports 9 | =========== 10 | 11 | When `reporting a bug `_ please include: 12 | 13 | * Your operating system name and version. 14 | * Any details about your local setup that might be helpful in troubleshooting. 15 | * Detailed steps to reproduce the bug. 16 | 17 | Documentation improvements 18 | ========================== 19 | 20 | img2cmap could always use more documentation, whether as part of the 21 | official img2cmap docs, in docstrings, or even on the web in blog posts, 22 | articles, and such. 23 | 24 | Feature requests and feedback 25 | ============================= 26 | 27 | The best way to send feedback is to file an issue at https://github.com/arvkevi/img2cmap/issues. 28 | 29 | If you are proposing a feature: 30 | 31 | * Explain in detail how it would work. 32 | * Keep the scope as narrow as possible, to make it easier to implement. 33 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 34 | 35 | Development 36 | =========== 37 | 38 | To set up `img2cmap` for local development: 39 | 40 | 1. Fork `img2cmap `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:YOURGITHUBNAME/img2cmap.git 45 | 46 | 3. Create a branch for local development:: 47 | 48 | git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 4. Install development requirements:: 53 | 54 | pip install img2cmap[dev] 55 | 56 | 5. When you're done making changes run all the checks and docs builder with `tox `_ one command:: 57 | 58 | tox 59 | 60 | 6. Commit your changes and push your branch to GitHub:: 61 | 62 | git add . 63 | git commit -m "Your detailed description of your changes." 64 | git push origin name-of-your-bugfix-or-feature 65 | 66 | 7. Submit a pull request through the GitHub website. 67 | 68 | Pull Request Guidelines 69 | ----------------------- 70 | 71 | If you need some code review or feedback while you're developing the code just make the pull request. 72 | 73 | For merging, you should: 74 | 75 | 1. Include passing tests (run ``tox``). 76 | 2. Update documentation when there's new API, functionality etc. 77 | 3. Add a note to ``CHANGELOG.rst`` about the changes. 78 | 4. Add yourself to ``AUTHORS.rst``. 79 | 80 | 81 | 82 | Tips 83 | ---- 84 | 85 | To run a subset of tests:: 86 | 87 | tox -e envname -- pytest -k test_myfeature 88 | 89 | To run all the test environments in *parallel*:: 90 | 91 | tox -p auto 92 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | LABEL authors="arvkevi@gmail.com" 3 | 4 | EXPOSE 8501 5 | 6 | WORKDIR /app 7 | COPY . /app 8 | 9 | RUN apt-get update \ 10 | && apt-get install --yes --no-install-recommends \ 11 | gcc g++ libffi-dev 12 | RUN pip install --upgrade pip 13 | RUN pip install .[streamlit] 14 | 15 | ENTRYPOINT [ "streamlit", "run"] 16 | CMD ["/app/streamlit/app.py"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022, Kevin Arvai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft src 3 | graft ci 4 | graft tests 5 | 6 | include .bumpversion.cfg 7 | include .cookiecutterrc 8 | include .coveragerc 9 | include .editorconfig 10 | include .github/workflows/github-actions.yml 11 | include .pre-commit-config.yaml 12 | include .readthedocs.yml 13 | include pytest.ini 14 | include tox.ini 15 | recursive-include images * 16 | include *.toml 17 | include Dockerfile 18 | recursive-include streamlit *.py 19 | 20 | include AUTHORS.rst 21 | include CHANGELOG.rst 22 | include CONTRIBUTING.rst 23 | include LICENSE 24 | include README.rst 25 | 26 | global-exclude *.py[cod] __pycache__/* *.so *.dylib 27 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | img2cmap 3 | ======== 4 | 5 | Usage 6 | ===== 7 | 8 | **Create colormaps from images in three lines of code!** 9 | 10 | | First, ``ImageConverter`` class converts images to arrays of RGB values. 11 | | Then, ``generate_cmap`` creates a matplotlib `ListedColormap `_. 12 | 13 | .. code-block:: python3 14 | 15 | from img2cmap import ImageConverter 16 | 17 | # Can be a local file or URL 18 | converter = ImageConverter("tests/images/south_beach_sunset.jpg") 19 | cmap = converter.generate_cmap(n_colors=5, palette_name="south_beach_sunset", random_state=42) 20 | 21 | Now, use the colormap in your plots! 22 | 23 | .. code-block:: python3 24 | 25 | import matplotlib.pyplot as plt 26 | 27 | colors = cmap.colors 28 | 29 | with plt.style.context("dark_background"): 30 | for i, color in enumerate(colors): 31 | plt.plot(range(10), [_+i+1 for _ in range(10)], color=color, linewidth=4) 32 | 33 | .. image:: images/img2cmap_demo.png 34 | :align: center 35 | 36 | 37 | Plot the image and a colorbar side by side. 38 | 39 | .. code-block:: python3 40 | 41 | import matplotlib.pyplot as plt 42 | from mpl_toolkits.axes_grid1 import make_axes_locatable 43 | 44 | fig, ax = plt.subplots(figsize=(7, 5)) 45 | 46 | ax.axis("off") 47 | img = plt.imread("tests/images/south_beach_sunset.jpg") 48 | im = ax.imshow(img, cmap=cmap) 49 | 50 | divider = make_axes_locatable(ax) 51 | cax = divider.append_axes("right", size="10%", pad=0.05) 52 | 53 | cb = fig.colorbar(im, cax=cax, orientation="vertical", label=cmap.name) 54 | cb.set_ticks([]) 55 | 56 | .. image:: images/colorbar.png 57 | :align: center 58 | 59 | 60 | Advanced 61 | -------- 62 | 63 | generate_optimal_cmap 64 | ^^^^^^^^^^^^^^^^^^^^^ 65 | 66 | You can extract the optimal number of colors from the image using the ``generate_optimal_cmap`` method. 67 | Under the hood this performs the `elbow method ` 68 | to determine the optimal number of clusters based on the sum of the squared distances between each pixel 69 | and it's cluster center. 70 | 71 | 72 | .. code-block:: python3 73 | 74 | cmaps, best_n_colors, ssd = converter.generate_optimal_cmap(max_colors=10, random_state=42) 75 | 76 | best_cmap = cmaps[best_n_colors] 77 | 78 | 79 | remove_transparent 80 | ^^^^^^^^^^^^^^^^^^^ 81 | 82 | In an image of the Los Angeles Lakers logo, the background is transparent. These pixels 83 | contribute to noise when generating the colors. Running the ``remove_transparent`` method 84 | will remove transparent pixels. Here's a comparison of the colormaps generated by the same image, 85 | without and with transparency removed. 86 | 87 | Make two ImageConverter objects: 88 | 89 | .. code-block:: python3 90 | 91 | from img2cmap import ImageConverter 92 | 93 | image_url = "https://loodibee.com/wp-content/uploads/nba-los-angeles-lakers-logo.png" 94 | 95 | # Create two ImageConverters, one with transparency removed and one without 96 | converter_with_transparent = ImageConverter(image_url) 97 | converter_with_transparent.remove_transparent() 98 | 99 | converter_no_transparent = ImageConverter(image_url) 100 | 101 | cmap_with_transparent = converter_with_transparent.generate_cmap( 102 | n_colors=3, palette_name="with_transparent", random_state=42 103 | ) 104 | cmap_no_transparent = converter_no_transparent.generate_cmap( 105 | n_colors=3, palette_name="no_transparent", random_state=42 106 | ) 107 | 108 | 109 | Plot both colormaps with the image: 110 | 111 | .. code-block:: python3 112 | 113 | import matplotlib.pyplot as plt 114 | from mpl_toolkits.axes_grid1 import make_axes_locatable 115 | 116 | for cmap in [cmap_with_transparent, cmap_no_transparent]: 117 | fig, ax = plt.subplots(figsize=(7, 5)) 118 | 119 | ax.axis("off") 120 | img = converter_no_transparent.image 121 | im = ax.imshow(img, cmap=cmap) 122 | 123 | divider = make_axes_locatable(ax) 124 | cax = divider.append_axes("right", size="10%", pad=0.05) 125 | 126 | cb = fig.colorbar(im, cax=cax, orientation="vertical", label=cmap.name) 127 | cb.set_ticks([]) 128 | 129 | 130 | .. image:: images/lakers_with_transparent.png 131 | :align: center 132 | 133 | .. image:: images/lakers_no_transparent.png 134 | :align: center 135 | 136 | Notice, only after removing the transparent pixels, does the classic purple and gold show in the colormap. 137 | 138 | 139 | resize 140 | ^^^^^^ 141 | 142 | There is a method of the ImageConverter class to resize images. It will preserve the aspect ratio, but reduce the size 143 | of the image. 144 | 145 | .. code-block:: python3 146 | 147 | def test_resize(): 148 | imageconverter = ImageConverter("tests/images/south_beach_sunset.jpg") 149 | imageconverter.resize(size=(512, 512)) 150 | # preserves aspect ratio 151 | assert imageconverter.image.size == (512, 361) 152 | 153 | 154 | hexcodes 155 | ^^^^^^^^ 156 | 157 | When running the ``generate_cmap`` or the ``generate_optimal_cmap`` methods the ImageConverter object will automatically 158 | capture the resulting hexcodes from the colormap and store them as an attribute. 159 | 160 | .. code-block:: python3 161 | 162 | from img2cmap import ImageConverter 163 | 164 | image_url = "https://static1.bigstockphoto.com/3/2/3/large1500/323952496.jpg" 165 | 166 | converter = ImageConverter(image_url) 167 | converter.generate_cmap(n_colors=4, palette_name="with_transparent", random_state=42) 168 | print(converter.hexcodes) 169 | 170 | 171 | Output: 172 | 173 | :: 174 | 175 | ['#ba7469', '#dfd67d', '#5d536a', '#321e28'] 176 | 177 | Installation 178 | ============ 179 | 180 | :: 181 | 182 | pip install img2cmap 183 | 184 | You can also install the in-development version with:: 185 | 186 | pip install https://github.com/arvkevi/img2cmap/archive/main.zip 187 | 188 | 189 | Documentation 190 | ============= 191 | 192 | 193 | https://img2cmap.readthedocs.io/ 194 | 195 | 196 | Web App 197 | ======= 198 | 199 | Check out the web app at https://img2cmap.fly.dev 200 | 201 | .. image:: images/webapp_image.png 202 | :align: center 203 | 204 | Status 205 | ====== 206 | 207 | 208 | .. start-badges 209 | 210 | .. list-table:: 211 | :stub-columns: 1 212 | 213 | * - docs 214 | - |docs| 215 | * - tests 216 | - | |github-actions| 217 | | |codecov| 218 | * - package 219 | - | |version| |wheel| |supported-versions| |supported-implementations| 220 | .. |docs| image:: https://readthedocs.org/projects/img2cmap/badge/?style=flat 221 | :target: https://img2cmap.readthedocs.io/ 222 | :alt: Documentation Status 223 | 224 | .. |github-actions| image:: https://github.com/arvkevi/img2cmap/actions/workflows/github-actions.yml/badge.svg 225 | :alt: GitHub Actions Build Status 226 | :target: https://github.com/arvkevi/img2cmap/actions 227 | 228 | .. |codecov| image:: https://codecov.io/gh/arvkevi/img2cmap/branch/main/graphs/badge.svg?branch=main 229 | :alt: Coverage Status 230 | :target: https://codecov.io/github/arvkevi/img2cmap 231 | 232 | .. |version| image:: https://img.shields.io/pypi/v/img2cmap.svg 233 | :alt: PyPI Package latest release 234 | :target: https://pypi.org/project/img2cmap 235 | 236 | .. |wheel| image:: https://img.shields.io/pypi/wheel/img2cmap.svg 237 | :alt: PyPI Wheel 238 | :target: https://pypi.org/project/img2cmap 239 | 240 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/img2cmap.svg 241 | :alt: Supported versions 242 | :target: https://pypi.org/project/img2cmap 243 | 244 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/img2cmap.svg 245 | :alt: Supported implementations 246 | :target: https://pypi.org/project/img2cmap 247 | 248 | 249 | 250 | .. end-badges 251 | 252 | 253 | Development 254 | =========== 255 | 256 | Install the development requirements: 257 | 258 | :: 259 | 260 | pip install img2cmap[dev] 261 | 262 | To run all the tests run:: 263 | 264 | tox 265 | 266 | Note, to combine the coverage data from all the tox environments run: 267 | 268 | .. list-table:: 269 | :widths: 10 90 270 | :stub-columns: 1 271 | 272 | - - Windows 273 | - :: 274 | 275 | set PYTEST_ADDOPTS=--cov-append 276 | tox 277 | 278 | - - Other 279 | - :: 280 | 281 | PYTEST_ADDOPTS=--cov-append tox 282 | -------------------------------------------------------------------------------- /ci/bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import absolute_import 4 | from __future__ import print_function 5 | from __future__ import unicode_literals 6 | 7 | import os 8 | import subprocess 9 | import sys 10 | from os.path import abspath 11 | from os.path import dirname 12 | from os.path import exists 13 | from os.path import join 14 | from os.path import relpath 15 | 16 | base_path = dirname(dirname(abspath(__file__))) 17 | templates_path = join(base_path, "ci", "templates") 18 | 19 | 20 | def check_call(args): 21 | print("+", *args) 22 | subprocess.check_call(args) 23 | 24 | 25 | def exec_in_env(): 26 | env_path = join(base_path, ".tox", "bootstrap") 27 | if sys.platform == "win32": 28 | bin_path = join(env_path, "Scripts") 29 | else: 30 | bin_path = join(env_path, "bin") 31 | if not exists(env_path): 32 | import subprocess 33 | 34 | print("Making bootstrap env in: {0} ...".format(env_path)) 35 | try: 36 | check_call([sys.executable, "-m", "venv", env_path]) 37 | except subprocess.CalledProcessError: 38 | try: 39 | check_call([sys.executable, "-m", "virtualenv", env_path]) 40 | except subprocess.CalledProcessError: 41 | check_call(["virtualenv", env_path]) 42 | print("Installing `jinja2` into bootstrap environment...") 43 | check_call([join(bin_path, "pip"), "install", "jinja2", "tox"]) 44 | python_executable = join(bin_path, "python") 45 | if not os.path.exists(python_executable): 46 | python_executable += '.exe' 47 | 48 | print("Re-executing with: {0}".format(python_executable)) 49 | print("+ exec", python_executable, __file__, "--no-env") 50 | os.execv(python_executable, [python_executable, __file__, "--no-env"]) 51 | 52 | 53 | def main(): 54 | import jinja2 55 | 56 | print("Project path: {0}".format(base_path)) 57 | 58 | jinja = jinja2.Environment( 59 | loader=jinja2.FileSystemLoader(templates_path), 60 | trim_blocks=True, 61 | lstrip_blocks=True, 62 | keep_trailing_newline=True, 63 | ) 64 | 65 | tox_environments = [ 66 | line.strip() 67 | # 'tox' need not be installed globally, but must be importable 68 | # by the Python that is running this script. 69 | # This uses sys.executable the same way that the call in 70 | # cookiecutter-pylibrary/hooks/post_gen_project.py 71 | # invokes this bootstrap.py itself. 72 | for line in subprocess.check_output([sys.executable, '-m', 'tox', '--listenvs'], universal_newlines=True).splitlines() 73 | ] 74 | tox_environments = [line for line in tox_environments if line.startswith('py')] 75 | 76 | for root, _, files in os.walk(templates_path): 77 | for name in files: 78 | relative = relpath(root, templates_path) 79 | with open(join(base_path, relative, name), "w") as fh: 80 | fh.write(jinja.get_template(join(relative, name)).render(tox_environments=tox_environments)) 81 | print("Wrote {}".format(name)) 82 | print("DONE.") 83 | 84 | 85 | if __name__ == "__main__": 86 | args = sys.argv[1:] 87 | if args == ["--no-env"]: 88 | main() 89 | elif not args: 90 | exec_in_env() 91 | else: 92 | print("Unexpected arguments {0}".format(args), file=sys.stderr) 93 | sys.exit(1) 94 | -------------------------------------------------------------------------------- /ci/requirements.txt: -------------------------------------------------------------------------------- 1 | virtualenv>=16.6.0 2 | pip>=19.1.1 3 | setuptools>=18.0.1 4 | six>=1.14.0 5 | requests 6 | tox 7 | -------------------------------------------------------------------------------- /ci/templates/.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: {{ '${{ matrix.name }}' }} 6 | runs-on: {{ '${{ matrix.os }}' }} 7 | timeout-minutes: 30 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - name: 'check' 13 | python: '3.9' 14 | toxpython: 'python3.9' 15 | tox_env: 'check' 16 | os: 'ubuntu-latest' 17 | - name: 'docs' 18 | python: '3.9' 19 | toxpython: 'python3.9' 20 | tox_env: 'docs' 21 | os: 'ubuntu-latest' 22 | {% for env in tox_environments %} 23 | {% set prefix = env.split('-')[0] -%} 24 | {% if prefix.startswith('pypy') %} 25 | {% set python %}pypy-{{ prefix[4] }}.{{ prefix[5] }}{% endset %} 26 | {% set cpython %}pp{{ prefix[4:5] }}{% endset %} 27 | {% set toxpython %}pypy{{ prefix[4] }}.{{ prefix[5] }}{% endset %} 28 | {% else %} 29 | {% set python %}{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 30 | {% set cpython %}cp{{ prefix[2:] }}{% endset %} 31 | {% set toxpython %}python{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 32 | {% endif %} 33 | {% for os, python_arch in [ 34 | ['ubuntu', 'x64'], 35 | ['windows', 'x64'], 36 | ['macos', 'x64'], 37 | ] %} 38 | - name: '{{ env }} ({{ os }})' 39 | python: '{{ python }}' 40 | toxpython: '{{ toxpython }}' 41 | python_arch: '{{ python_arch }}' 42 | tox_env: '{{ env }}{% if 'cover' in env %},codecov{% endif %}' 43 | os: '{{ os }}-latest' 44 | {% endfor %} 45 | {% endfor %} 46 | steps: 47 | - uses: actions/checkout@v2 48 | with: 49 | fetch-depth: 0 50 | - uses: actions/setup-python@v2 51 | with: 52 | python-version: {{ '${{ matrix.python }}' }} 53 | architecture: {{ '${{ matrix.python_arch }}' }} 54 | - name: install dependencies 55 | run: | 56 | python -mpip install --progress-bar=off -r ci/requirements.txt 57 | virtualenv --version 58 | pip --version 59 | tox --version 60 | pip list --format=freeze 61 | - name: test 62 | env: 63 | TOXPYTHON: '{{ '${{ matrix.toxpython }}' }}' 64 | run: > 65 | tox -e {{ '${{ matrix.tox_env }}' }} -v 66 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import os 5 | 6 | extensions = [ 7 | "sphinx.ext.autodoc", 8 | "sphinx.ext.autosummary", 9 | "sphinx.ext.coverage", 10 | "sphinx.ext.doctest", 11 | "sphinx.ext.extlinks", 12 | "sphinx.ext.ifconfig", 13 | "sphinx.ext.napoleon", 14 | "sphinx.ext.todo", 15 | "sphinx.ext.viewcode", 16 | ] 17 | source_suffix = ".rst" 18 | master_doc = "index" 19 | project = "img2cmap" 20 | year = "2022" 21 | author = "Kevin Arvai" 22 | copyright = "{0}, {1}".format(year, author) 23 | version = release = "0.2.3" 24 | 25 | pygments_style = "trac" 26 | templates_path = ["."] 27 | extlinks = { 28 | "issue": ("https://github.com/arvkevi/img2cmap/issues/%s", "#"), 29 | "pr": ("https://github.com/arvkevi/img2cmap/pull/%s", "PR #"), 30 | } 31 | # on_rtd is whether we are on readthedocs.org 32 | on_rtd = os.environ.get("READTHEDOCS", None) == "True" 33 | 34 | if not on_rtd: # only set the theme if we're building docs locally 35 | html_theme = "sphinx_rtd_theme" 36 | 37 | html_use_smartypants = True 38 | html_last_updated_fmt = "%b %d, %Y" 39 | html_split_index = False 40 | html_sidebars = { 41 | "**": ["searchbox.html", "globaltoc.html", "sourcelink.html"], 42 | } 43 | html_short_title = "%s-%s" % (project, version) 44 | 45 | napoleon_use_ivar = True 46 | napoleon_use_rtype = False 47 | napoleon_use_param = False 48 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Contents 3 | ======== 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | readme 9 | installation 10 | usage 11 | reference/index 12 | contributing 13 | authors 14 | changelog 15 | 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | pip install img2cmap 8 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/reference/img2cmap.rst: -------------------------------------------------------------------------------- 1 | img2cmap 2 | ======== 3 | 4 | .. testsetup:: 5 | 6 | from img2cmap import * 7 | 8 | .. automodule:: img2cmap 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | .. toctree:: 5 | :glob: 6 | 7 | img2cmap* 8 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=1.3 2 | sphinx-rtd-theme 3 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | builtin 2 | builtins 3 | classmethod 4 | staticmethod 5 | classmethods 6 | staticmethods 7 | args 8 | kwargs 9 | callstack 10 | Changelog 11 | Indices 12 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use img2cmap in a project:: 6 | 7 | import img2cmap 8 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for img2cmap on 2022-05-17T21:43:01-04:00 2 | 3 | app = "img2cmap" 4 | 5 | kill_signal = "SIGINT" 6 | kill_timeout = 5 7 | processes = [] 8 | 9 | [env] 10 | 11 | [experimental] 12 | allowed_public_ports = [] 13 | auto_rollback = true 14 | 15 | [[services]] 16 | http_checks = [] 17 | internal_port = 8501 18 | processes = ["app"] 19 | protocol = "tcp" 20 | script_checks = [] 21 | 22 | [services.concurrency] 23 | hard_limit = 25 24 | soft_limit = 20 25 | type = "connections" 26 | 27 | [[services.ports]] 28 | force_https = true 29 | handlers = ["http"] 30 | port = 80 31 | 32 | [[services.ports]] 33 | handlers = ["tls", "http"] 34 | port = 443 35 | 36 | [[services.tcp_checks]] 37 | grace_period = "1s" 38 | interval = "15s" 39 | restart_limit = 0 40 | timeout = "2s" 41 | -------------------------------------------------------------------------------- /images/colorbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arvkevi/img2cmap/82811743a7b3c645a73e87dcc18c17a3844f1362/images/colorbar.png -------------------------------------------------------------------------------- /images/img2cmap_demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arvkevi/img2cmap/82811743a7b3c645a73e87dcc18c17a3844f1362/images/img2cmap_demo.png -------------------------------------------------------------------------------- /images/lakers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arvkevi/img2cmap/82811743a7b3c645a73e87dcc18c17a3844f1362/images/lakers.png -------------------------------------------------------------------------------- /images/lakers_no_transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arvkevi/img2cmap/82811743a7b3c645a73e87dcc18c17a3844f1362/images/lakers_no_transparent.png -------------------------------------------------------------------------------- /images/lakers_with_transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arvkevi/img2cmap/82811743a7b3c645a73e87dcc18c17a3844f1362/images/lakers_with_transparent.png -------------------------------------------------------------------------------- /images/webapp_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arvkevi/img2cmap/82811743a7b3c645a73e87dcc18c17a3844f1362/images/webapp_image.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "img2cmap" 7 | version = "0.2.3" 8 | authors = [ 9 | { name="Kevin Arvai", email="arvkevi@gmail.com" }, 10 | { name="Marshall Krassenstein", email="mpkrass@gmail.com"}, 11 | ] 12 | description = "Create colormaps from images" 13 | readme = "README.rst" 14 | requires-python = ">=3.7" 15 | classifiers = [ 16 | "License :: OSI Approved :: MIT License", 17 | "Development Status :: 2 - Pre-Alpha", 18 | "Operating System :: OS Independent", 19 | "Intended Audience :: Developers", 20 | "Operating System :: Unix", 21 | "Operating System :: POSIX", 22 | "Operating System :: Microsoft :: Windows", 23 | "Operating System :: MacOS", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3 :: Only", 27 | "Programming Language :: Python :: 3.7", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: Implementation :: CPython", 32 | "Topic :: Utilities", 33 | ] 34 | keywords = ["colormap", "matplotlib", "kmeans", "data visualization"] 35 | dependencies = [ 36 | "matplotlib>=3.4.2", 37 | "scikit-learn>=0.24.2", 38 | "numpy>=1.20.3", 39 | "pillow>=8.0.1", 40 | "kneed >=0.8.1", 41 | ] 42 | 43 | [project.optional-dependencies] 44 | dev = ["black", "requests", "tox"] 45 | streamlit= ["streamlit>=1.29.0", "st-annotated-text"] 46 | all = ["black", "requests", "tox", "streamlit", "st-annotated-text"] 47 | 48 | [project.license] 49 | file = "LICENSE" 50 | 51 | [project.urls] 52 | "Homepage" = "https://github.com/arvkevi/kneed" 53 | "Bug Tracker" = "https://github.com/arvkevi/kneed/issues" 54 | 55 | [tool.black] 56 | line-length = 140 57 | target-version = ['py37', 'py38', 'py39', 'py310'] 58 | skip-string-normalization = false 59 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # If a pytest section is found in one of the possible config files 3 | # (pytest.ini, tox.ini or setup.cfg), then pytest will not look for any others, 4 | # so if you add a pytest config section elsewhere, 5 | # you will need to delete this section from setup.cfg. 6 | norecursedirs = 7 | migrations 8 | 9 | python_files = 10 | test_*.py 11 | *_test.py 12 | tests.py 13 | addopts = 14 | -ra 15 | --strict-markers 16 | --doctest-modules 17 | --doctest-glob=\*.rst 18 | --tb=short 19 | testpaths = 20 | tests 21 | 22 | # Idea from: https://til.simonwillison.net/pytest/treat-warnings-as-errors 23 | filterwarnings = 24 | error 25 | # You can add exclusions, some examples: 26 | # ignore:'img2cmap' defines default_app_config:PendingDeprecationWarning:: 27 | # ignore:The {{% if::: 28 | # ignore:Coverage disabled via --no-cov switch! 29 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool.setuptools.packages.find] 2 | where = ["src"] 3 | 4 | [tool.setuptools.exclude-package-data] 5 | img2cmap = ["tests"] 6 | 7 | [flake8] 8 | max-line-length = 140 9 | exclude = .tox,.eggs,ci/templates,build,dist 10 | per-file-ignores = src/img2cmap/__init__.py:F401 11 | 12 | 13 | [tool:isort] 14 | force_single_line = True 15 | line_length = 120 16 | known_first_party = img2cmap 17 | default_section = THIRDPARTY 18 | forced_separate = test_img2cmap 19 | skip = .tox,.eggs,ci/templates,build,dist 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | 6 | setup() 7 | -------------------------------------------------------------------------------- /src/img2cmap/__init__.py: -------------------------------------------------------------------------------- 1 | from .convert import ImageConverter # noqa: F401, E999 2 | 3 | __version__ = "0.2.3" 4 | -------------------------------------------------------------------------------- /src/img2cmap/convert.py: -------------------------------------------------------------------------------- 1 | import colorsys 2 | from pathlib import Path 3 | from urllib.error import HTTPError 4 | from urllib.error import URLError 5 | from urllib.request import urlopen 6 | 7 | import matplotlib as mpl 8 | import numpy as np 9 | from kneed import KneeLocator 10 | from PIL import Image 11 | from sklearn.cluster import MiniBatchKMeans 12 | 13 | 14 | class ImageConverter: 15 | """Converts an image to numpy array of RGB values. 16 | 17 | Args: 18 | image_path str: The path to the image. Can be a local file or a URL. 19 | 20 | Attributes: 21 | image_path (str): The path to the image. Can be a local file or a URL. 22 | image (PIL.Image): The image object. 23 | pixels (numpy.ndarray): A numpy array of RGB values. 24 | """ 25 | 26 | def __init__(self, image_path): 27 | self.image_path = image_path 28 | # try to open the image 29 | try: 30 | self.image = Image.open(self.image_path) 31 | except (FileNotFoundError, OSError): 32 | try: 33 | self.image = Image.open(urlopen(self.image_path)) 34 | except (URLError, HTTPError) as error: 35 | raise URLError(f"Could not open {self.image_path} {error}") from error 36 | except ValueError as error: 37 | raise ValueError(f"Could not open {self.image_path} {error}") from error 38 | 39 | # convert the image to a numpy array 40 | self.image = self.image.convert("RGBA") 41 | self.pixels = np.array(self.image.getdata()) 42 | 43 | # Find transparent pixels and store them in case we want to remove transparency 44 | self.transparent_pixels = self.pixels[:, 3] == 0 45 | self.pixels = self.pixels[:, :3] 46 | self.kmeans = None 47 | self.hexcodes = None 48 | 49 | def generate_cmap(self, n_colors=4, palette_name=None, random_state=None): 50 | """Generates a matplotlib ListedColormap from an image. 51 | 52 | Args: 53 | n_colors (int, optional): The number of colors in the ListedColormap. Defaults to 4. 54 | palette_name (str, optional): A name for your created palette. If None, defaults to the image name. 55 | Defaults to None. 56 | random_state (int, optional): A random seed for reproducing ListedColormaps. 57 | The k-means algorithm has a random initialization step and doesn't always converge on the same 58 | solution because of this. If None will be a different seed each time this method is called. 59 | Defaults to None. 60 | 61 | Returns: 62 | matplotlib.colors.ListedColormap: A matplotlib ListedColormap object. 63 | """ 64 | # create a kmeans model 65 | self.kmeans = MiniBatchKMeans(n_clusters=n_colors, random_state=random_state, n_init=3) 66 | # fit the model to the pixels 67 | self.kmeans.fit(self.pixels) 68 | # get the cluster centers 69 | centroids = self.kmeans.cluster_centers_ / 255 70 | # return the palette 71 | if palette_name is None: 72 | palette_name = Path(self.image_path).stem 73 | 74 | cmap = mpl.colors.ListedColormap(centroids, name=palette_name) 75 | 76 | # Handle 4 dimension RGBA colors 77 | cmap.colors = cmap.colors[:, :3] 78 | 79 | # Sort colors by hue 80 | cmap.colors = sorted(cmap.colors, key=lambda rgb: colorsys.rgb_to_hsv(*rgb)) 81 | # Handle cases where all rgb values evaluate to 1 or 0. This is a temporary fix 82 | cmap.colors = np.where(np.isclose(cmap.colors, 1), 1 - 1e-6, cmap.colors) 83 | cmap.colors = np.where(np.isclose(cmap.colors, 0), 1e-6, cmap.colors) 84 | 85 | self.hexcodes = [mpl.colors.rgb2hex(c) for c in cmap.colors] 86 | return cmap 87 | 88 | def generate_optimal_cmap(self, max_colors=10, palette_name=None, random_state=None): 89 | """Generates an optimal matplotlib ListedColormap from an image by finding the optimal number of clusters using the elbow method. 90 | 91 | Useage: 92 | >>> img = ImageConverter("path/to/image.png") 93 | >>> cmaps, best_n_colors, ssd = img.generate_optimal_cmap() 94 | >>> # The optimal colormap 95 | >>> cmaps[best_n_colors] 96 | 97 | 98 | Args: 99 | max_colors (int, optional): _description_. Defaults to 10. 100 | palette_name (_type_, optional): _description_. Defaults to None. 101 | random_state (_type_, optional): _description_. Defaults to None. 102 | remove_background (_type_, optional): _description_. Defaults to None. 103 | 104 | Returns: 105 | dict: A dictionary of matplotlib ListedColormap objects. 106 | Keys are the number of colors (clusters). Values are ListedColormap objects. 107 | int: The optimal number of colors. 108 | dict: A dictionary of the sum of square distances from each point to the cluster center. 109 | Keys are the number of colors (clusters) and values are the SSD value. 110 | """ 111 | ssd = dict() 112 | cmaps = dict() 113 | for n_colors in range(2, max_colors + 1): 114 | cmap = self.generate_cmap(n_colors=n_colors, palette_name=palette_name, random_state=random_state) 115 | cmaps[n_colors] = cmap 116 | ssd[n_colors] = self.kmeans.inertia_ 117 | 118 | best_n_colors = KneeLocator(list(ssd.keys()), list(ssd.values()), curve="convex", direction="decreasing").knee 119 | try: 120 | self.hexcodes = [mpl.colors.rgb2hex(c) for c in cmaps[best_n_colors].colors] 121 | except KeyError: 122 | # Kneed did not find an optimal point so we don't record any hex values 123 | self.hexcodes = None 124 | return cmaps, best_n_colors, ssd 125 | 126 | def resize(self, size=(512, 512)): 127 | """Resizes the image to the specified size. 128 | 129 | Args: 130 | size (tuple): The new size of the image. 131 | 132 | Returns: 133 | None 134 | """ 135 | try: 136 | resampling_technique = Image.Resampling.LANCZOS 137 | # py36 138 | except AttributeError: 139 | resampling_technique = Image.LANCZOS 140 | 141 | self.image.thumbnail(size, resampling_technique) 142 | 143 | def remove_transparent(self): 144 | """Removes the transparent pixels from an image array. 145 | 146 | Returns: 147 | None 148 | """ 149 | self.pixels = self.pixels[~self.transparent_pixels] 150 | -------------------------------------------------------------------------------- /streamlit/app.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from io import BytesIO 3 | from pprint import pformat 4 | 5 | import matplotlib as mpl 6 | import matplotlib.patches as patches 7 | import matplotlib.pyplot as plt 8 | import numpy as np 9 | from annotated_text import annotated_text 10 | from mpl_toolkits.axes_grid1 import make_axes_locatable 11 | 12 | import streamlit as st 13 | from img2cmap import ImageConverter 14 | 15 | 16 | def colorpicker(color): 17 | """Logic to decide between black or white text for a given background color. 18 | https://stackoverflow.com/a/3943023/4541548 19 | """ 20 | red, green, blue = mpl.colors.to_rgb(color) 21 | newrgb = [] 22 | for c in red, green, blue: 23 | c = c / 255.0 24 | if c <= 0.04045: 25 | newrgb.append(c / 12.92) 26 | else: 27 | newrgb.append(((c + 0.055) / 1.055) ^ 2.4) 28 | L = 0.2126 * newrgb[0] + 0.7152 * newrgb[1] + 0.0722 * newrgb[2] 29 | # why did I have to use 179 instead of 0.179? 30 | if L > 0.179 / 1000: 31 | return "#000000" 32 | else: 33 | return "#ffffff" 34 | 35 | 36 | # @profile 37 | def main(): 38 | warnings.filterwarnings("ignore") 39 | # st.set_option("deprecation.showfileUploaderEncoding", False) 40 | 41 | st.set_page_config( 42 | page_title="img2cmap web", 43 | layout="wide", 44 | ) 45 | 46 | st.title("Convert images to a colormap") 47 | st.markdown( 48 | """ 49 | This app converts images to colormaps using the Python 50 | library [img2cmap](https://github.com/arvkevi/img2cmap). 51 | Try your own image on the left. **Scroll down to generate an optimal colormap.** 52 | """ 53 | ) 54 | 55 | st.sidebar.markdown("### Image settings") 56 | file_or_url = st.sidebar.radio("Upload an image file or paste an image URL", ("file", "url")) 57 | 58 | if file_or_url == "file": 59 | user_image = st.sidebar.file_uploader("Upload an image file") 60 | if user_image is not None: 61 | user_image = BytesIO(user_image.getvalue()) 62 | elif file_or_url == "url": 63 | user_image = st.sidebar.text_input("Paste an image URL", "https://static1.bigstockphoto.com/3/2/3/large1500/323952496.jpg") 64 | else: 65 | st.warning("Please select an option") 66 | 67 | # default image to use 68 | if user_image is None: 69 | user_image = "https://raw.githubusercontent.com/arvkevi/img2cmap/main/tests/images/south_beach_sunset.jpg" 70 | 71 | # user settings 72 | st.sidebar.markdown("### User settings") 73 | n_colors = st.sidebar.number_input( 74 | "Number of colors", min_value=2, max_value=20, value=5, help="The number of colors to return in the colormap" 75 | ) 76 | n_colors = int(n_colors) 77 | remove_transparent = st.sidebar.checkbox( 78 | "Remove transparency", False, help="If checked, remove transparent pixels from the image before clustering." 79 | ) 80 | random_state = st.sidebar.number_input("Random state", value=42, help="Random state for reproducibility") 81 | random_state = int(random_state) 82 | 83 | @st.cache_data 84 | def get_image_converter(user_image, remove_transparent): 85 | converter = ImageConverter(user_image) 86 | if remove_transparent: 87 | converter.remove_transparent() 88 | return converter 89 | 90 | converter = get_image_converter(user_image, remove_transparent) 91 | converter.resize() 92 | 93 | with st.spinner("Generating colormap..."): 94 | cmap = converter.generate_cmap(n_colors=n_colors, palette_name="", random_state=random_state) 95 | 96 | # plot the image and colorbar 97 | fig1, ax1 = plt.subplots(figsize=(8, 8)) 98 | 99 | ax1.axis("off") 100 | img = converter.image 101 | im = ax1.imshow(img, cmap=cmap) 102 | 103 | divider = make_axes_locatable(ax1) 104 | cax = divider.append_axes("right", size="10%", pad=0.05) 105 | 106 | cb = fig1.colorbar(im, cax=cax, orientation="vertical", label=cmap.name) 107 | cb.set_ticks([]) 108 | st.pyplot(fig1) 109 | 110 | colors1 = [mpl.colors.rgb2hex(c) for c in cmap.colors] 111 | 112 | # determine whether to show the text in white or black 113 | bw_mask = [colorpicker(c) for c in colors1] 114 | 115 | st.header("Hex Codes") 116 | annotated_text(*[(hexcode, "", hexcode, text_color) for hexcode, text_color in zip(colors1, bw_mask)]) 117 | st.code(colors1) 118 | st.caption("Click copy button on far right to copy hex codes to clipboard.") 119 | st.header("Detect optimal number of colors") 120 | col1, _, _, _, _ = st.columns(5) 121 | max_colors = col1.number_input("Max number of colors in cmap (more colors = longer runtime)", min_value=2, max_value=20, value=10) 122 | optimize = st.button("Optimize") 123 | if optimize: 124 | with st.spinner("Optimizing... (this can take up to a minute)"): 125 | cmaps, best_n_colors, ssd = converter.generate_optimal_cmap(max_colors=max_colors, palette_name="", random_state=random_state) 126 | 127 | figopt, ax = plt.subplots(figsize=(7, 5)) 128 | 129 | ymax = max_colors + 1 130 | xmax = max_colors 131 | ax.set_ylim(2, ymax) 132 | ax.set_xlim(0, max_colors) 133 | 134 | # i will be y axis 135 | for y, cmap_ in cmaps.items(): 136 | # Fix small 137 | colors = sorted([mpl.colors.rgb2hex(c) for c in cmap_.colors]) 138 | intervals, width = np.linspace(0, xmax, len(colors) + 1, retstep=True) 139 | # j will be x axis 140 | for j, color in enumerate(colors): 141 | rect = patches.Rectangle((intervals[j], y), width, 1, facecolor=color) 142 | ax.add_patch(rect) 143 | 144 | ax.set_yticks(np.arange(2, ymax) + 0.5) 145 | ax.set_yticklabels(np.arange(2, ymax)) 146 | ax.set_ylabel("Number of colors") 147 | ax.set_xticks([]) 148 | 149 | # best 150 | rect = patches.Rectangle((0, best_n_colors), ymax, 1, linewidth=1, facecolor="none", edgecolor="black", linestyle="--") 151 | ax.add_patch(rect) 152 | 153 | # minus 2, one for starting at 2 and one for 0-indexing 154 | ax.get_yticklabels()[best_n_colors - 2].set_color("red") 155 | st.pyplot(figopt) 156 | st.metric("Optimal number of colors", best_n_colors) 157 | st.text("Hex Codes of optimal colormap (click to copy on far right)") 158 | st.code(sorted([mpl.colors.rgb2hex(c) for c in cmaps[best_n_colors].colors])) 159 | st.text("Hex Codes of all colormaps {k: [hex codes]}") 160 | st.code(pformat({k: sorted([mpl.colors.rgb2hex(c) for c in v.colors]) for k, v in cmaps.items()})) 161 | 162 | 163 | if __name__ == "__main__": 164 | main() 165 | -------------------------------------------------------------------------------- /tests/images/black_square.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arvkevi/img2cmap/82811743a7b3c645a73e87dcc18c17a3844f1362/tests/images/black_square.jpg -------------------------------------------------------------------------------- /tests/images/colorful_city.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arvkevi/img2cmap/82811743a7b3c645a73e87dcc18c17a3844f1362/tests/images/colorful_city.png -------------------------------------------------------------------------------- /tests/images/movie_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arvkevi/img2cmap/82811743a7b3c645a73e87dcc18c17a3844f1362/tests/images/movie_chart.png -------------------------------------------------------------------------------- /tests/images/south_beach_sunset.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arvkevi/img2cmap/82811743a7b3c645a73e87dcc18c17a3844f1362/tests/images/south_beach_sunset.jpg -------------------------------------------------------------------------------- /tests/test_img2cmap.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | import pytest 5 | import requests 6 | 7 | from img2cmap import ImageConverter 8 | 9 | THIS_DIR = Path(__file__).parent 10 | 11 | test_image_files = list(THIS_DIR.joinpath("images").iterdir()) 12 | image_urls = [ 13 | "https://static1.bigstockphoto.com/3/2/3/large1500/323952496.jpg", 14 | "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT4N0qxKmiCah2If-5M4Dw7Lb5MPb6w7eNKog&usqp=CAU", 15 | ] 16 | 17 | # Make sure web urls are valid 18 | test_image_urls = [] 19 | for url in image_urls: 20 | response = requests.get(url) 21 | if response.status_code == 200: 22 | test_image_urls.append(url) 23 | else: 24 | print(f"Could not get image from {url}") 25 | 26 | images = test_image_files + test_image_urls 27 | 28 | 29 | @pytest.mark.parametrize("test_image_input", images) 30 | def test_generate_cmap_1(test_image_input): 31 | imageconverter = ImageConverter(test_image_input) 32 | cmap = imageconverter.generate_cmap(4, "miami", 42) 33 | assert cmap.name == "miami" 34 | 35 | 36 | @pytest.mark.parametrize("test_image_input", images) 37 | def test_generate_cmap_2(test_image_input): 38 | imageconverter = ImageConverter(test_image_input) 39 | cmap = imageconverter.generate_cmap(4, "miami", 42) 40 | assert cmap.N == 4 41 | 42 | 43 | @pytest.mark.parametrize("test_image_input", test_image_files) 44 | def test_generate_cmap_3(test_image_input): 45 | cmap_default_name = test_image_input.stem 46 | imageconverter = ImageConverter(test_image_input) 47 | cmap = imageconverter.generate_cmap(4, None, 42) 48 | assert cmap.name == cmap_default_name 49 | 50 | 51 | @pytest.mark.parametrize("test_image_input", images) 52 | def test_generate_cmap_4(test_image_input): 53 | with pytest.raises(ValueError): 54 | imageconverter = ImageConverter(test_image_input) 55 | imageconverter.generate_cmap(-100, "miami", 42) 56 | 57 | 58 | @pytest.mark.parametrize("test_image_input", images) 59 | def test_cmap_color_dimension(test_image_input): 60 | imageconverter = ImageConverter(test_image_input) 61 | cmap = imageconverter.generate_cmap(4, "Miami Yeaaaa", 42) 62 | assert cmap.colors.shape[1] == 3 63 | 64 | 65 | @pytest.mark.parametrize("test_image_input", images) 66 | def test_cmap_optimal_plot(test_image_input): 67 | imageconverter = ImageConverter(test_image_input) 68 | cmaps, _, _ = imageconverter.generate_optimal_cmap(random_state=42) 69 | for _, cmap_ in cmaps.items(): 70 | assert not np.any(np.all(np.isclose(cmap_.colors, 1, atol=1e-9))) 71 | assert not np.any(np.all(np.isclose(cmap_.colors, 0, atol=1e-9))) 72 | 73 | 74 | # TODO: Mock these! 75 | # def test_url(): 76 | # with open(THIS_DIR.joinpath("urls/nba-logos.txt"), "r") as f: 77 | # for line in f: 78 | # if "miami" in line: 79 | # url = line.strip() 80 | # break 81 | # converter = ImageConverter(url) 82 | # cmap = converter.generate_cmap(2, "miami", 42) 83 | # assert cmap.name == "miami" 84 | 85 | 86 | # @pytest.mark.parametrize("test_remove_transparent", [True, False]) 87 | # def test_remove_transparent(test_remove_transparent): 88 | # """This image should not have any black pixels.""" 89 | # with open(THIS_DIR.joinpath("urls/nba-logos.txt"), "r") as f: 90 | # for line in f: 91 | # if "atlanta" in line: 92 | # url = line.strip() 93 | # converter = ImageConverter(url, remove_transparent=test_remove_transparent) 94 | # cmap = converter.generate_cmap(3, 42) 95 | # hex_codes = [mpl.colors.rgb2hex(c) for c in cmap.colors] 96 | # black_not_in_colors = not any(["#000000" in c for c in hex_codes]) 97 | # assert black_not_in_colors == test_remove_transparent 98 | 99 | 100 | def test_generate_optimal(): 101 | imageconverter = ImageConverter(THIS_DIR.joinpath("images/south_beach_sunset.jpg")) 102 | _, best_n_colors, _ = imageconverter.generate_optimal_cmap(random_state=42) 103 | assert best_n_colors == 5 104 | 105 | 106 | def test_resize(): 107 | imageconverter = ImageConverter(THIS_DIR.joinpath("images/south_beach_sunset.jpg")) 108 | imageconverter.resize(size=(512, 512)) 109 | # thumbnail preserves the aspect ratio 110 | assert imageconverter.image.size == (512, 361) 111 | 112 | 113 | @pytest.mark.parametrize("test_image_input", images) 114 | def test_remove_transparency(test_image_input): 115 | imageconverter = ImageConverter(test_image_input) 116 | imageconverter.remove_transparent() 117 | cmap = imageconverter.generate_cmap(4, "miami", 42) 118 | assert cmap.N == 4 119 | 120 | 121 | @pytest.mark.parametrize("test_image_input", images) 122 | def test_compute_hexcodes(test_image_input): 123 | imageconverter = ImageConverter(test_image_input) 124 | imageconverter.generate_cmap(4, "miami", 42) 125 | 126 | assert imageconverter.hexcodes is not None 127 | assert len(imageconverter.hexcodes) == 4 128 | 129 | 130 | def test_compute_optimal_hexcodes(): 131 | imageconverter = ImageConverter(image_urls[0]) 132 | imageconverter.generate_optimal_cmap(max_colors=8, random_state=42) 133 | assert imageconverter.hexcodes is not None 134 | 135 | 136 | def test_break_kneed(): 137 | with pytest.raises(ValueError): 138 | imageconverter = ImageConverter(image_urls[0]) 139 | imageconverter.generate_optimal_cmap(max_colors=1, random_state=42) 140 | -------------------------------------------------------------------------------- /tests/urls/nba-logos.txt: -------------------------------------------------------------------------------- 1 | https://loodibee.com/wp-content/uploads/nba-atlanta-hawks-logo.png 2 | https://loodibee.com/wp-content/uploads/nba-boston-celtics-logo.png 3 | https://loodibee.com/wp-content/uploads/nba-brooklyn-nets-logo.png 4 | https://loodibee.com/wp-content/uploads/nba-charlotte-hornets-logo.png 5 | https://loodibee.com/wp-content/uploads/nba-chicago-bulls-logo.png 6 | https://loodibee.com/wp-content/uploads/nba-cleveland-cavaliers-logo.png 7 | https://loodibee.com/wp-content/uploads/nba-dallas-mavericks-logo.png 8 | https://loodibee.com/wp-content/uploads/nba-denver-nuggets-logo.png 9 | https://loodibee.com/wp-content/uploads/nba-detroit-pistons-logo.png 10 | https://loodibee.com/wp-content/uploads/nba-golden-state-warriors-logo.png 11 | https://loodibee.com/wp-content/uploads/nba-houston-rockets-logo.png 12 | https://loodibee.com/wp-content/uploads/nba-indiana-pacers-logo.png 13 | https://loodibee.com/wp-content/uploads/nba-los-angeles-clippers-logo.png 14 | https://loodibee.com/wp-content/uploads/nba-los-angeles-lakers-logo.png 15 | https://loodibee.com/wp-content/uploads/nba-memphis-grizzlies-logo.png 16 | https://loodibee.com/wp-content/uploads/nba-miami-heat-logo.png 17 | https://loodibee.com/wp-content/uploads/nba-milwaukee-bucks-logo.png 18 | https://loodibee.com/wp-content/uploads/nba-minnesota-timberwolves-logo.png 19 | https://loodibee.com/wp-content/uploads/nba-new-orleans-pelicans-logo.png 20 | https://loodibee.com/wp-content/uploads/nba-new-york-knicks-logo.png 21 | https://loodibee.com/wp-content/uploads/nba-oklahoma-city-thunder-logo.png 22 | https://loodibee.com/wp-content/uploads/nba-orlando-magic-logo.png 23 | https://loodibee.com/wp-content/uploads/nba-philadelphia-76ers-logo.png 24 | https://loodibee.com/wp-content/uploads/nba-phoenix-suns-logo.png 25 | https://loodibee.com/wp-content/uploads/nba-portland-trail-blazers-logo.png 26 | https://loodibee.com/wp-content/uploads/nba-sacramento-kings-logo.png 27 | https://loodibee.com/wp-content/uploads/nba-san-antonio-spurs-logo.png 28 | https://loodibee.com/wp-content/uploads/nba-toronto-raptors-logo.png 29 | https://loodibee.com/wp-content/uploads/nba-utah-jazz-logo.png 30 | https://loodibee.com/wp-content/uploads/nba-washington-wizards-logo.png 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv:bootstrap] 2 | deps = 3 | jinja2 4 | tox 5 | skip_install = true 6 | commands = 7 | python ci/bootstrap.py --no-env 8 | passenv = 9 | * 10 | 11 | [tox] 12 | envlist = 13 | clean, 14 | check, 15 | docs, 16 | {py37,py38,py39,py310}, 17 | report 18 | ignore_basepython_conflict = true 19 | 20 | [testenv] 21 | basepython = 22 | py37: {env:TOXPYTHON:python3.7} 23 | py38: {env:TOXPYTHON:python3.8} 24 | py39: {env:TOXPYTHON:python3.9} 25 | py310: {env:TOXPYTHON:python3.10} 26 | {bootstrap,clean,check,report,docs,codecov}: {env:TOXPYTHON:python3} 27 | setenv = 28 | PYTHONPATH={toxinidir}/tests 29 | PYTHONUNBUFFERED=yes 30 | passenv = PYTHON_VERSION 31 | 32 | 33 | usedevelop = false 34 | deps = 35 | pytest 36 | pytest-cov 37 | requests 38 | commands = 39 | {posargs:pytest --cov --cov-report=term-missing -vv tests} 40 | 41 | [testenv:check] 42 | deps = 43 | docutils 44 | check-manifest 45 | flake8 46 | readme-renderer 47 | pygments 48 | isort 49 | skip_install = true 50 | commands = 51 | python setup.py check --strict --metadata --restructuredtext 52 | check-manifest {toxinidir} 53 | flake8 54 | isort --verbose --check-only --diff --filter-files . 55 | 56 | [testenv:docs] 57 | usedevelop = true 58 | deps = 59 | -r{toxinidir}/docs/requirements.txt 60 | commands = 61 | sphinx-build {posargs:-E} -b html docs dist/docs 62 | sphinx-build -b linkcheck docs dist/docs 63 | 64 | [testenv:codecov] 65 | deps = 66 | codecov 67 | skip_install = true 68 | commands = 69 | codecov [] 70 | 71 | [testenv:report] 72 | deps = 73 | coverage 74 | skip_install = true 75 | commands = 76 | coverage report 77 | coverage html 78 | 79 | [testenv:clean] 80 | commands = coverage erase 81 | skip_install = true 82 | deps = 83 | coverage 84 | --------------------------------------------------------------------------------