diff --git a/.binder/environment.yml b/.binder/environment.yml new file mode 100644 index 0000000..62ed690 --- /dev/null +++ b/.binder/environment.yml @@ -0,0 +1,12 @@ +# based on https://github.com/jupyterlab-contrib/jupyterlab-vim/blob/master/binder/environment.yml +name: mpl-image-labller-demo + +channels: + - conda-forge + +dependencies: + # runtime dependencies + - python >=3.8,<3.10.0a0 + - jupyterlab >=3,<4.0.0a0 + - pip + - ipympl diff --git a/.binder/postBuild b/.binder/postBuild new file mode 100644 index 0000000..02fdd1c --- /dev/null +++ b/.binder/postBuild @@ -0,0 +1 @@ +python -m pip install -vvv -e . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cad8631..11c9345 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,3 +36,12 @@ repos: rev: v0.812 hooks: - id: mypy + - repo: https://github.com/nbQA-dev/nbQA + rev: 1.1.1 + hooks: + - id: nbqa-black + - id: nbqa-isort + - repo: https://github.com/kynan/nbstripout + rev: 0.5.0 + hooks: + - id: nbstripout diff --git a/README.md b/README.md index d738852..628883f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ # mpl-image-labeller +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/ianhi/mpl-image-labeller/main?urlpath=lab) +[![Documentation Status](https://readthedocs.org/projects/mpl-image-labeller/badge/?version=stable)](https://mpl-image-labeller.readthedocs.io/en/stable/?badge=stable) + + [![License](https://img.shields.io/pypi/l/mpl-image-labeller.svg?color=green)](https://github.com/ianhi/mpl-image-labeller/raw/master/LICENSE) [![PyPI](https://img.shields.io/pypi/v/mpl-image-labeller.svg?color=green)](https://pypi.org/project/mpl-image-labeller) [![Python Version](https://img.shields.io/pypi/pyversions/mpl-image-labeller.svg?color=green)](https://python.org) Use Matplotlib to label images for classification. Works anywhere Matplotlib does - from the notebook to a standalone gui! +For more see the [documentation](https://mpl-image-labeller.readthedocs.io/en/stable/?badge=stable). + ## Install ```bash @@ -21,7 +27,13 @@ pip install mpl-image-labeller - Smart interactions with default Matplotlib keymap - Callback System (see `examples/callbacks.py`) -![gif of usage for labelling images of cats and dogs](example.gif) +**single class per image** + +![gif of usage for labelling images of cats and dogs](docs/_static/single_class.gif) + +**multiple classes per image** + +![gif of usage for labelling images of cats and dogs](docs/_static/multi_class.gif) ## Usage diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..6ff62d7 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,23 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= -T --color +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +watch: + sphinx-autobuild . _build/html --open-browser --watch examples diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..c47c61c --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,22 @@ +/* Fix numpydoc format delimiters */ +.classifier:before { + font-style: normal; + margin: 0.5em; + content: ":"; +} + +/* override table no-wrap */ +.wy-table-responsive table td, +.wy-table-responsive table th { + white-space: normal; +} + +.text-align\:left, text-align\:left > p { + text-align: left +} +.text-align\:center, text-align\:center > p { + text-align: center +} +.text-align\:center, text-align\:right > p { + text-align: right +} diff --git a/docs/_static/multi_class.gif b/docs/_static/multi_class.gif new file mode 100644 index 0000000..e98733a Binary files /dev/null and b/docs/_static/multi_class.gif differ diff --git a/docs/_static/single_class.gif b/docs/_static/single_class.gif new file mode 100644 index 0000000..36e5f23 Binary files /dev/null and b/docs/_static/single_class.gif differ diff --git a/docs/api/mpl_image_labeller.rst b/docs/api/mpl_image_labeller.rst new file mode 100644 index 0000000..4d53cef --- /dev/null +++ b/docs/api/mpl_image_labeller.rst @@ -0,0 +1,10 @@ +mpl\_image\_labeller package +============================ + +Module contents +--------------- + +.. automodule:: mpl_image_labeller + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..a631ae7 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,228 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +import inspect + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import shutil +import subprocess +import sys + +try: + from mpl_image_labeller import __version__ as release +except ImportError: + release = "unknown" + + +# -- Project information ----------------------------------------------------- + +project = "mpl-image-labeller" +copyright = "2021, Ian Hunt-Isaak" +author = "Ian Hunt-Isaak" + + +# -- Generate API ------------------------------------------------------------ +api_folder_name = "api" +shutil.rmtree(api_folder_name, ignore_errors=True) # in case of new or renamed modules +subprocess.call( + " ".join( + [ + "sphinx-apidoc", + f"-o {api_folder_name}/", + "--force", + "--no-toc", + "--templatedir _templates", + "--separate", + "../mpl_image_labeller/", + # excluded modules + # nothing here for cookiecutter + ] + ), + shell=True, +) + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "jupyter_sphinx", + "myst_nb", + "numpydoc", + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.linkcode", + "sphinx.ext.mathjax", + "sphinx.ext.napoleon", + "sphinx_copybutton", + "sphinx_panels", + "sphinx_thebe", + "sphinx_togglebutton", +] + + +# API settings +autodoc_default_options = { + "members": True, + "show-inheritance": True, + "undoc-members": True, +} +add_module_names = False +napoleon_google_docstring = False +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = False +napoleon_numpy_docstring = True +napoleon_use_admonition_for_examples = False +napoleon_use_admonition_for_notes = False +napoleon_use_admonition_for_references = False +napoleon_use_ivar = False +napoleon_use_param = False +napoleon_use_rtype = False +numpydoc_show_class_members = False + +# Cross-referencing configuration +default_role = "py:obj" +primary_domain = "py" +nitpicky = True # warn if cross-references are missing + +# Intersphinx settings +intersphinx_mapping = { + "ipywidgets": ("https://ipywidgets.readthedocs.io/en/stable", None), + "matplotlib": ("https://matplotlib.org/stable", None), + "numpy": ("https://numpy.org/doc/stable", None), + "python": ("https://docs.python.org/3", None), +} + +# remove panels css to get wider main content +panels_add_bootstrap_css = False + +# Settings for copybutton +copybutton_prompt_is_regexp = True +copybutton_prompt_text = r">>> |\.\.\. " # doctest + +# Settings for linkcheck +linkcheck_anchors = False +linkcheck_ignore = [] # type: ignore + +execution_timeout = -1 +jupyter_execute_notebooks = "off" +if "EXECUTE_NB" in os.environ: + print("\033[93;1mWill run Jupyter notebooks!\033[0m") + jupyter_execute_notebooks = "force" + +# Settings for myst-parser +myst_enable_extensions = [ + "amsmath", + "colon_fence", + "dollarmath", + "smartquotes", + "substitution", +] +suppress_warnings = [ + "myst.header", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [ + "**ipynb_checkpoints", + ".DS_Store", + "Thumbs.db", + "_build", +] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_copy_source = True # needed for download notebook button +html_css_files = [ + "custom.css", +] +html_sourcelink_suffix = "" +html_static_path = ["_static"] +html_theme = "sphinx_book_theme" +html_theme_options = { + "launch_buttons": { + "binderhub_url": "https://mybinder.org", + "colab_url": "https://colab.research.google.com", + "notebook_interface": "jupyterlab", + "thebe": True, + "thebelab": True, + }, + "path_to_docs": "docs", + "repository_branch": "main", + "repository_url": "https://github.com/ianhi/mpl-image-labeller", + "use_download_button": True, + "use_edit_page_button": True, + "use_issues_button": True, + "use_repository_button": True, +} +html_title = "mpl-image-labeller" + +master_doc = "index" +thebe_config = { + "repository_url": html_theme_options["repository_url"], + "repository_branch": html_theme_options["repository_branch"], +} + + +# based on pandas/doc/source/conf.py +def linkcode_resolve(domain, info): + """ + Determine the URL corresponding to Python object + """ + if domain != "py": + return None + + modname = info["module"] + fullname = info["fullname"] + + submod = sys.modules.get(modname) + if submod is None: + return None + + obj = submod + for part in fullname.split("."): + try: + obj = getattr(obj, part) + except AttributeError: + return None + + try: + fn = inspect.getsourcefile(inspect.unwrap(obj)) + except TypeError: + fn = None + if not fn: + return None + + try: + source, lineno = inspect.getsourcelines(obj) + except OSError: + lineno = None + + if lineno: + linespec = f"#L{lineno}-L{lineno + len(source) - 1}" + else: + linespec = "" + + fn = os.path.relpath(fn, start=os.path.dirname("../mpl_image_labeller")) + + return f"https://github.com/ianhi/mpl-image-labeller/blob/main/mpl_image_labeller/{fn}{linespec}" # noqa diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..74f9349 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,67 @@ +# Contributing + +Thanks for thinking of a way to help improve this library! Remember that contributions come in all shapes and sizes beyond writing bug fixes. Contributing to [documentation](#documentation), opening new [issues](https://github.com/ianhi/mpl-image-labeller/issues) for bugs, asking for clarification on things you find unclear, and requesting new features, are all super valuable contributions. + +## Code Improvements + +All development for this library happens on GitHub [here](https://github.com/ianhi/mpl-image-labeller). We recommend you work with a [Conda](https://www.anaconda.com/products/individual) environment (or an alternative virtual environment like [`venv`](https://docs.python.org/3/library/venv.html)). + +The below instructions also use [Mamba](https://github.com/mamba-org/mamba#the-fast-cross-platform-package-manager) which is a very fast implementation of `conda`. + +```bash +git clone +cd mpl-interactions +mamba env create +conda activate mpl-interactions +pre-commit install +``` + +The `mamba env create` command installs all Python packages that are useful when working on the source code of `mpl_image_labeller` and its documentation. You can also install these packages separately: + +```bash +pip install -e ".[dev, doc]" +``` + +The {command}`-e .` flag installs the `mpl_image_labeller` folder in ["editable" mode](https://pip.pypa.io/en/stable/cli/pip_install/#editable-installs) and {command}`[dev]` installs the [optional dependencies](https://setuptools.readthedocs.io/en/latest/userguide/dependency_management.html#optional-dependencies) you need for developing `mpl_image_labeller`. + +### Seeing your changes + +If you are working in a Jupyter Notebook, then in order to see your code changes you will need to either: + +- Restart the Kernel every time you make a change to the code. +- Make the function reload from the source file every time you run it by using [autoreload](https://ipython.readthedocs.io/en/stable/config/extensions/autoreload.html), e.g.: + + ```python + %load_ext autoreload + %autoreload 2 + + from mpl_image_labeller import .... + ``` + +### Working with Git + +Using Git/GitHub can confusing (), so if you're new to Git, you may find it helpful to use a program like [GitHub Desktop](https://desktop.github.com) and to follow a [guide](https://github.com/firstcontributions/first-contributions#first-contributions). + +Also feel free to ask for help/advice on the relevant GitHub [issue](https://github.com/ianhi/mpl-interactions/issues). + +## Documentation + +Our documentation on Read the Docs ([mpl-interactions.rtfd.io](https://mpl-interactions.readthedocs.io)) is built with [Sphinx](https://www.sphinx-doc.org) from the notebooks in the `docs` folder. It contains both Markdown files and Jupyter notebooks. + +Examples are best written as Jupyter notebooks. To write a new example, create in a notebook in the `docs/examples` directory and list its path under one of the [`toctree`s](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-toctree) in the `index.md` file. When the docs are generated, they will be rendered as static html pages by [myst-nb](https://myst-nb.readthedocs.io). + +If you have installed all developer dependencies (see [above](#contributing)), you can view recent modifications to the source files the following simple tox command: + +```bash +tox -e doc +``` + +If you open the `index.html` file in your browser you should now be able to see the rendered documentation. + +Alternatively, you can use [sphinx-autobuild](https://github.com/executablebooks/sphinx-autobuild) to continuously watch source files for changes and rebuild the documentation for you. Sphinx-autobuild will be installed automatically by the above `pip` command, so all you need to do is run: + +```bash +tox -e doclive +``` + +In a few seconds your web browser should open up the documentation. Now whenever you save a file the documentation will automatically regenerate and the webpage will refresh for you! diff --git a/docs/examples/callbacks.ipynb b/docs/examples/callbacks.ipynb new file mode 100644 index 0000000..82678dc --- /dev/null +++ b/docs/examples/callbacks.ipynb @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3982f572-4151-47df-923d-3cd6794e7070", + "metadata": {}, + "source": [ + "# Callbacks\n", + "\n", + "The image labeller implements a callback system that allows you to run arbitrary code whenever the displayed image changes or when an image has a label assigned." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eebbcc16-dd8a-4e9b-9d79-b7cb2d3dbee0", + "metadata": {}, + "outputs": [], + "source": [ + "# If in a notebook\n", + "%matplotlib ipympl" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f576ccc0-d8e8-4d6c-b09c-5c3634c94bf6", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from mpl_image_labeller import image_labeller\n", + "\n", + "images = np.random.randn(5, 10, 10)\n", + "labeller = image_labeller(images, classes=[\"good\", \"bad\", \"blarg\"])\n", + "\n", + "\n", + "def image_changed_callback(index, image):\n", + " print(index)\n", + " print(image.sum())\n", + "\n", + "\n", + "def label_assigned(index, label):\n", + " print(f\"label {label} assigned to image {index}\")\n", + "\n", + "\n", + "labeller.on_image_changed(image_changed_callback)\n", + "labeller.on_label_assigned(image_changed_callback)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "1e4c6ae8-c124-4e05-a9d2-6c4d987d9cf7", + "metadata": {}, + "source": [ + "## Overlaying a mask\n", + "\n", + "One potential usage of this is to overlay a mask over the images which changes for each image" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "352f6e5e-7b0b-44aa-bb19-da2bc6bca654", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "labeller = image_labeller(images, classes=[\"good\", \"bad\", \"blarg\"])\n", + "\n", + "mask_threshold = 0.6\n", + "\n", + "from numpy.random import default_rng\n", + "\n", + "\n", + "def gen_mask(idx, image):\n", + " # here we return a random mask - but you could base this on your data\n", + " rng = default_rng(idx)\n", + " mask = rng.random(image.shape)\n", + " return mask > mask_threshold\n", + "\n", + "\n", + "overlay = labeller.ax.imshow(\n", + " gen_mask(0, images[0]), cmap=\"gray\", vmin=0, vmax=mask_threshold, alpha=0.75\n", + ")\n", + "cmap = overlay.cmap.copy()\n", + "cmap.set_over(alpha=0)\n", + "overlay.set_cmap(cmap)\n", + "\n", + "\n", + "def update_mask(idx, image):\n", + " overlay.set_data(gen_mask(idx, image))\n", + "\n", + "\n", + "labeller.on_image_changed(update_mask)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/examples/lazy-loading.ipynb b/docs/examples/lazy-loading.ipynb new file mode 100644 index 0000000..24f791b --- /dev/null +++ b/docs/examples/lazy-loading.ipynb @@ -0,0 +1,87 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7b2b704c-a2b1-4497-bd93-b6619804d848", + "metadata": {}, + "source": [ + "# Images from a function (Lazy loading)\n", + "\n", + "You do not need to provide an array of images. Instead you can provide a function that returns an image given an index. This enables the follow:\n", + "\n", + "1. Lazy loading of images\n", + "2. Easily have images of different sizes (the image will update limits automatically)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "483bffb1-f291-40ad-80ba-e9e08b8f6e88", + "metadata": {}, + "outputs": [], + "source": [ + "# if in a notebook\n", + "%matplotlib ipympl" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b62a762f-26be-4dba-ab07-ec26648b970b", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# You can lazy load images by providing a function instead of a list for *images*\n", + "# if you do this then you must also provide *N_images* in the labeller constructor\n", + "\n", + "\n", + "import matplotlib.pyplot as plt\n", + "from numpy.random import default_rng\n", + "\n", + "from mpl_image_labeller import image_labeller\n", + "\n", + "\n", + "def lazy_image_generator(idx):\n", + " rng = default_rng(idx)\n", + " return rng.random((rng.integers(5, 15), rng.integers(5, 15)))\n", + "\n", + "\n", + "labeller = image_labeller(\n", + " lazy_image_generator, classes=[\"cool\", \"rad\", \"lame\"], N_images=57\n", + ")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8000249c-babc-430d-bd29-7a55145ce0bc", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/examples/multi-class.ipynb b/docs/examples/multi-class.ipynb new file mode 100644 index 0000000..9335a56 --- /dev/null +++ b/docs/examples/multi-class.ipynb @@ -0,0 +1,108 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "10d8e4fe-11be-478a-bc33-a81bb0dce987", + "metadata": {}, + "source": [ + "# Multi Class\n", + "\n", + "You can also allow each image to belong to multiple categories with the `multiclass` argument." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7aa1379-5cbd-4284-8d99-373bfd02c807", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# If in a notebook:\n", + "%matplotlib ipympl" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7db1d50-2fe0-4fa5-9af9-1f0753acd34d", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from mpl_image_labeller import image_labeller\n", + "\n", + "images = np.random.randn(5, 10, 10)\n", + "labeller = image_labeller(\n", + " images,\n", + " classes=[\"good\", \"bad\", \"meh\"],\n", + " label_keymap=[\"a\", \"s\", \"d\"],\n", + " multiclass=True,\n", + ")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4471612b-e5f7-4a55-8232-536da875fb29", + "metadata": {}, + "source": [ + "The natural representation of this multiclass is a onehot encoding accessible (and settable!) via the `labels_onehot` property." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b724f277-2eb9-4d5c-b0a7-0820ce4b20d2", + "metadata": {}, + "outputs": [], + "source": [ + "print(labeller.labels_onehot)" + ] + }, + { + "cell_type": "markdown", + "id": "2cf1e17e-8a18-4a56-b9d8-4be61fe4bd47", + "metadata": {}, + "source": [ + "If you can you can also get the labels as a ragged list of lists via the `labels` property" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "700dcf86-7337-4b50-aab0-ddd7346a24d9", + "metadata": {}, + "outputs": [], + "source": [ + "print(labeller.labels)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/examples/single-class.ipynb b/docs/examples/single-class.ipynb new file mode 100644 index 0000000..b004000 --- /dev/null +++ b/docs/examples/single-class.ipynb @@ -0,0 +1,123 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cdf469e7-0110-4b75-97d5-69c25aa290f5", + "metadata": {}, + "source": [ + "# Single Class\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4a694f4d-ba0d-4ea4-b15e-db17a2fa8e61", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# If running in a notebook make matplotlib interactive\n", + "%matplotlib ipympl" + ] + }, + { + "cell_type": "markdown", + "id": "8b0938f1-fab9-4127-b836-9c06a2fbc333", + "metadata": {}, + "source": [ + "```{note}\n", + "In a notebook you need to make sure to click on the figure in order to give it keyboard focus.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "65afcfa0-1511-4721-a79a-8df969a1002f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from mpl_image_labeller import image_labeller\n", + "\n", + "images = np.random.randn(5, 10, 10)\n", + "labeller = image_labeller(\n", + " images, classes=[\"good\", \"bad\", \"meh\"], label_keymap=[\"a\", \"s\", \"d\"]\n", + ")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "f517f339-a7e1-4bd5-bff4-fc56d9948a1d", + "metadata": {}, + "source": [ + "After you label the images then the labels will be available as a list:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c79afd96-4f6d-46f8-ab7d-46b5dc6de38e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "print(labeller.labels)" + ] + }, + { + "cell_type": "markdown", + "id": "7416b621-d9be-4e5d-9600-14f341ee419b", + "metadata": {}, + "source": [ + "Or as a onehot encoding" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cca10064-0f61-47c3-b526-5d1330170c61", + "metadata": {}, + "outputs": [], + "source": [ + "print(labeller.labels_onehot)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "253335a9-17ea-4dfd-96a4-580f8593ac1d", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..0ec08b7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,61 @@ + +# mpl-image-labeller's Documentation + +Use Matplotlib to label images for classification. Works anywhere Matplotlib does - from the notebook to a standalone gui! + + +## Key features +- Single or Multiclass interfaces! + - {doc}`examples/single-class` + - {doc}`examples/multi-class` +- Supports lists of classes or onehot encodings +- Uses keys instead of mouse +- Only depends on Matplotlib + - Works anywhere - from inside Jupyter to any supported GUI framework +- Displays images with correct aspect ratio +- Easily configurable keymap +- Smart interactions with default Matplotlib keymap +- Callback System (see {doc}`examples/callbacks`) +- Allows Lazy Loading of Images ({doc}`examples/lazy-loading`) + +## Install +```bash +pip install mpl-image-labeller +``` + +## Example GIFs +```{table} +:align: center + +| Single Class Interface | Multiclass Interface | +| ----------------------- | --------------------| +|![A gif of the single class interface. Showing keybindings to assign classes to images.](_static/single_class.gif) | ![A gif of the multi-class interface. Showing using both keybindings and mouse to assign classes to images.](_static/multi_class.gif)| +``` + +## Getting help +Please ask usage questions on the [Matplotlib 3rd Party Package Discourse](https://discourse.matplotlib.org/c/3rdparty/18). When you do so feel free +to use `@ianhi` to ping me. + +## Reporting Issues +Please report any issues on github at https://github.com/ianhi/mpl-image-labeller/issues/new/choose + + + + + +```{toctree} +:maxdepth: 2 + +API +contributing +``` + +```{toctree} +:caption: Examples +:maxdepth: 1 + +examples/single-class.ipynb +examples/multi-class.ipynb +examples/lazy-loading.ipynb +examples/callbacks.ipynb +``` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..922152e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/example.gif b/example.gif deleted file mode 100644 index 47ee756..0000000 Binary files a/example.gif and /dev/null differ diff --git a/examples/multiclass_labeller.py b/examples/multiclass_labeller.py new file mode 100644 index 0000000..315819f --- /dev/null +++ b/examples/multiclass_labeller.py @@ -0,0 +1,15 @@ +import matplotlib.pyplot as plt +import numpy as np + +from mpl_image_labeller import image_labeller + +images = np.random.randn(5, 10, 10) +labeller = image_labeller( + images, + classes=["good", "bad", "meh"], + label_keymap=["a", "s", "d"], + multiclass=True, +) +plt.show() +print(labeller.labels) +print(labeller.labels_onehot) diff --git a/mpl_image_labeller/_labeller.py b/mpl_image_labeller/_labeller.py index 6749b59..ea56da6 100644 --- a/mpl_image_labeller/_labeller.py +++ b/mpl_image_labeller/_labeller.py @@ -5,6 +5,9 @@ from matplotlib.cbook import CallbackRegistry from matplotlib.figure import Figure +from ._util import ConflictingArgumentsError, list_to_onehot, onehot_to_list +from ._widgets import button_array + def gen_key_press_handler(skip_keys): def handler(event, canvas=None, toolbar=None): @@ -21,10 +24,12 @@ def __init__( images, classes, init_labels=None, + init_labels_onehot=None, label_keymap: Union[List[str], str] = "1234", labelling_advances_image: bool = True, N_images=None, fig: Figure = None, + multiclass=False, **imshow_kwargs, ): """ @@ -33,9 +38,16 @@ def __init__( images : (N, Y, X) ArrayLike classes : (N,) ArrayLike The available classes for the images. - init_labels: 1D ArrayLike, optional + multiclass : bool, default: False + Whether to allow for an image to have multiple classes or just one. + init_labels: list of list or list of str, optional The initial labels for the images. If given it must be the same length as - *images* + *images* and each entry should be either a single class or an iterable of + classes. Incompatible with *init_labels_onehot*. + init_labels_onehot: 2D ArrayLike, optional + The initial labels for the images as a onehot encoding. If given it must + have shape (N_images, N_classes) and be castable to a boolean array. + Incompatible with *init_labels*. label_keymap : list of str, or str If a str must be one of the predefined values *1234* (1, 2, 3,..), *qwerty* (q, w, e, r, t, y). If an iterable then the items will be assigned @@ -44,6 +56,7 @@ def __init__( longer perform savefig. labelling_advances_image : bool, default: True Whether labelling an image should advance to the next image. + Ignored if *multiclass* is True. N_images : int or None The number of images. Required if passing a Callable for images, otherwise ignored. @@ -53,6 +66,7 @@ def __init__( **imshow_kwargs : kwargs to be passed to the imshow function that displays the images. """ + self._multi = multiclass self._images = images if callable(images): if not isinstance(N_images, int): @@ -74,12 +88,9 @@ def _get_image(i): self._label_advances = labelling_advances_image - if init_labels is None: - self._labels = [None] * self._N_images - elif len(init_labels) != self._N_images: - raise ValueError("init_labels must have the same length as images") - else: - self._labels = init_labels + # TODO: sync this up with labels + # TODO: make sure init_labels does something here + self._onehot = np.zeros((self._N_images, len(classes)), dtype=bool) if label_keymap == "1234": if len(classes) > 10: @@ -95,10 +106,29 @@ def _get_image(i): "please provide a custom keymap" ) self._label_keymap = {"qwertyuiop"[c]: c for c in range(len(classes))} + elif len(label_keymap) != len(classes): + raise ValueError("label_keymap must have the same length as classes") else: self._label_keymap = {label_keymap[i]: i for i in range(len(label_keymap))} - self._classes = classes + # make array for easy indexing + self._classes = np.asarray(classes) + + if init_labels is not None and init_labels_onehot is not None: + raise ConflictingArgumentsError( + "init_labels and init_labels_onehot cannot both be *None*" + ) + + if init_labels is not None: + # length errors are handled in the setter + self.labels = init_labels + elif init_labels_onehot is not None: + self.labels_onehot = init_labels_onehot + else: + if self._multi: + self.labels = [[]] * self._N_images + else: + self.labels = [None] * self._N_images if fig is None: import matplotlib.pyplot as plt @@ -116,59 +146,85 @@ def _get_image(i): ) self._image_index = 0 - self._ax = self._fig.add_subplot(111) + if self._multi: + self._image_ax, self._button_ax = self._fig.subplots(1, 2) + else: + self._image_ax = self._fig.add_subplot(111) aspect = imshow_kwargs.pop("aspect", "equal") - self._im = self._ax.imshow(self._get_image(0), aspect=aspect, **imshow_kwargs) + self._im = self._image_ax.imshow( + self._get_image(0), aspect=aspect, **imshow_kwargs + ) - # shift axis to make room for list of keybindings - box = self._ax.get_position() - box.x0 = box.x0 - 0.20 - box.x1 = box.x1 - 0.20 - self._ax.set_position(box) - self._update_title() + if self._multi: - # these are matplotlib.patch.Patch properties - props = dict(boxstyle="round", facecolor="wheat", alpha=0.5) - - textstr = """Keybindings - <- : Previous Image - -> : Next Image""" - - self._ax.text( - 1.05, - 0.95, - textstr, - transform=self._ax.transAxes, - fontsize=14, - verticalalignment="top", - bbox=props, - horizontalalignment="left", - ) + def on_state_change(new_state, old_state): + self._onehot[self._image_index] = new_state + # self.labels[self._image_index] = self._classes[new_state] - textstr = """Class Keybindings:\n""" - for k, v in self._label_keymap.items(): - textstr += f"{k} : {self._classes[v]}\n" - - self._ax.text( - 1.05, - 0.55, - textstr, - transform=self._ax.transAxes, - fontsize=14, - verticalalignment="top", - bbox=props, - ) + texts = [] + for key, klass in zip(self._label_keymap.keys(), classes): + texts.append(f"[{key}]\n{str(klass)}") + self._buttons = button_array(texts, self._button_ax) + self._buttons.on_state_change(on_state_change) + else: + # shift axis to make room for list of keybindings + box = self._image_ax.get_position() + box.x0 = box.x0 - 0.20 + box.x1 = box.x1 - 0.20 + self._image_ax.set_position(box) + + # these are matplotlib.patch.Patch properties + props = dict(boxstyle="round", facecolor="wheat", alpha=0.5) + + textstr = """Keybindings + <- : Previous Image + -> : Next Image""" + + self._image_ax.text( + 1.05, + 0.95, + textstr, + transform=self._image_ax.transAxes, + fontsize=14, + verticalalignment="top", + bbox=props, + horizontalalignment="left", + ) + + textstr = """Class Keybindings:\n""" + for k, v in self._label_keymap.items(): + textstr += f"{k} : {self._classes[v]}\n" + + self._image_ax.text( + 1.05, + 0.55, + textstr, + transform=self._image_ax.transAxes, + fontsize=14, + verticalalignment="top", + bbox=props, + ) + self._update_title() self._fig.canvas.mpl_connect("key_press_event", self._key_press) self._observers = CallbackRegistry() @property def ax(self): - return self._ax + """ + **readonly** - The `~matplotlib.axes.Axes` object the image's are displayed on. + """ + return self._image_ax @property def labels(self): - return self._labels + """ + The current labels as a list of lists or a list of strings. + """ + if self._multi: + return onehot_to_list(self._onehot, self._classes) + else: + return self._labels @labels.setter def labels(self, value): @@ -176,10 +232,40 @@ def labels(self, value): raise ValueError( "Length of labels must be the same as the number of images" ) - self._labels = value + if self._multi: + self._onehot = list_to_onehot(value, self._classes) + else: + self._labels = value + + @property + def labels_onehot(self): + """ + The current labels as a one hot encoding. + """ + if self._multi: + return self._onehot + else: + return list_to_onehot(self._labels, self._classes) + + @labels_onehot.setter + def labels_onehot(self, value): + value = np.asanyarray(value) + expected_shape = (self._N_images, len(self._classes)) + if value.shape != expected_shape: + raise ValueError( + "One hot labels must have shape (N images, num classes." + f"Expected shape {expected_shape} but got {value.shape}" + ) + if self._multi: + self._onehot = value + else: + self._labels = onehot_to_list(value) @property def image_index(self): + """ + **int** the index of the currently displayed image. + """ return self._image_index @image_index.setter @@ -202,18 +288,25 @@ def image_index(self, value): self._update_displayed() def _update_title(self): - self._ax.set_title( - f"Image {self._image_index}\nLabel: {self._labels[self._image_index]}" - ) + text = f"Image {self._image_index}" + if not self._multi: + text += f"\nLabel: {self._labels[self._image_index]}" + + self._image_ax.set_title(text) def _update_displayed(self): image = np.asarray(self._get_image(self._image_index)) # for some reason this keeps getting turned off by something - self._ax.set_autoscale_on(True) + self._image_ax.set_autoscale_on(True) self._im.set_data(image) self._im.set_extent((-0.5, image.shape[1] - 0.5, image.shape[0] - 0.5, -0.5)) self._update_title() self._observers.process("image-changed", self._image_index, image) + if self._multi: + with self._buttons.no_callbacks(): + # TODO: check that this no_callbacks actually works.... + new_state = self._onehot[self._image_index] + self._buttons.set_states(new_state) self._fig.canvas.draw_idle() def _key_press(self, event): @@ -222,10 +315,16 @@ def _key_press(self, event): elif event.key == "right": self.image_index += 1 elif event.key in self._label_keymap: - klass = self._classes[self._label_keymap[event.key]] - self._labels[self._image_index] = klass + which_label = self._label_keymap[event.key] + klass = self._classes[which_label] + if self._multi: + img_labels = self._onehot[self._image_index] + img_labels[which_label] = not img_labels[which_label] + self._buttons.set_states(img_labels) + else: + self._labels[self._image_index] = klass self._observers.process("label-assigned", self._image_index, klass) - if self._label_advances: + if self._label_advances and not self._multi: if self.image_index == self._N_images - 1: # make sure we update the title we are on the last image self._update_title() diff --git a/mpl_image_labeller/_util.py b/mpl_image_labeller/_util.py new file mode 100644 index 0000000..1541926 --- /dev/null +++ b/mpl_image_labeller/_util.py @@ -0,0 +1,73 @@ +import contextlib +from collections.abc import Iterable + +import numpy as np +from matplotlib.cbook import CallbackRegistry + +__all__ = [ + "deactivatable_CallbackRegistry", + "add_text_to_rect", + "list_to_onehot", + "onehot_to_list", + "ConflictingArgumentsError", +] + + +class deactivatable_CallbackRegistry(CallbackRegistry): + def __init__(self, exception_handler=None): + if exception_handler is not None: + super().__init__(exception_handler) + else: + super().__init__() + self._active = True + + def process(self, s, *args, **kwargs): + """ + Process signal *s*. + + All of the functions registered to receive callbacks on *s* will be + called with ``*args`` and ``**kwargs``. + """ + if self._active: + super().process(s, *args, **kwargs) + + @contextlib.contextmanager + def deactivate(self): + self._active = False + yield + self._active = True + + +def add_text_to_rect(text, rect, **text_kwargs): + rx, ry = rect.get_xy() + cx = rx + rect.get_width() / 2.0 + cy = ry + rect.get_height() / 2.0 + ha = text_kwargs.pop("ha", "center") + va = text_kwargs.pop("va", "center") + rect.axes.annotate(text, (cx, cy), ha=ha, va=va, **text_kwargs) + + +def list_to_onehot(labels, classes): + lookup = {c: i for i, c in enumerate(classes)} + arr = np.zeros((len(labels), len(classes)), dtype=bool) + for i, l in enumerate(labels): + + if isinstance(l, str) or not isinstance(l, Iterable): + # str, or number, or something like that + arr[i, lookup[l]] = True + else: + for j in l: + arr[i, lookup[j]] = True + return arr + + +def onehot_to_list(onehot, classes): + c_arr = np.asarray(classes) + labels = [] + for row in onehot: + labels.append(list(c_arr[row])) + return labels + + +class ConflictingArgumentsError(ValueError): + pass diff --git a/mpl_image_labeller/_widgets.py b/mpl_image_labeller/_widgets.py new file mode 100644 index 0000000..17a9442 --- /dev/null +++ b/mpl_image_labeller/_widgets.py @@ -0,0 +1,132 @@ +import contextlib + +import numpy as np +from matplotlib.patches import Rectangle + +from ._util import add_text_to_rect, deactivatable_CallbackRegistry + + +class _array_button(Rectangle): + def __init__( + self, + x, + y, + width, + height, + active_color="green", + inactive_color="tab:red", + **rect_kwargs + ): + self._state = False + self.active_color = active_color + self.inactive_color = inactive_color + + super().__init__( + (x, y), width, height, facecolor=inactive_color, picker=True, **rect_kwargs + ) + + @property + def state(self): + return self._state + + @state.setter + def state(self, value): + if not isinstance(value, (bool, np.bool_)): + raise TypeError("Button state must be a bool") + self._state = value + self._update_color() + + def _update_color(self): + col = self.active_color if self.state else self.inactive_color + self.set_facecolor(col) + self.set_edgecolor(col) + self.stale = True + + +class button_array: + def __init__(self, options, ax, active_color="green", inactive_color="tab:red"): + self._ax = ax + self._fig = ax.figure + ax.axis("off") + # if len(options) <= 3: + # nrow = 1 + ncol = 4 + len(options) + + gap = 0.05 + width = (1 - (ncol - 1) * gap) / ncol + height = width + self._buttons = [] + self._active_color = active_color + self._inactive_color = inactive_color + nrow = np.ceil(len(options) / ncol) + total_height = nrow * height + top = 0.5 + (total_height / 2) + for i, o in enumerate(options): + vert_pos = top - ((i // ncol) * (height + gap)) - height + horiz_pos = (i % ncol) * (width + gap) + button = _array_button( + horiz_pos, vert_pos, width, height, active_color, inactive_color + ) + self._ax.add_artist(button) + add_text_to_rect(str(o), button) + self._buttons.append(button) + self._ax.figure.canvas.mpl_connect("pick_event", self._on_pick) + # self._ax.figure.canvas.mpl_connect("key_press_event", self._on_pick) + self.draw_on = True + self._observers = deactivatable_CallbackRegistry() + + def on_state_change(self, func): + """ + Connect *func* to be called the state of the checked buttons changes. + *func* will receive the updated state and the old state + + Maybe todo: also send the diff of the state. + """ + self._observers.connect( + "state-changed", lambda new_state, old_state: func(new_state, old_state) + ) + + def _on_pick(self, event): + if event.artist in self._buttons: + old_state = self.get_states() + event.artist.state = not event.artist.state + self._observers.process("state-changed", self.get_states(), old_state) + # TODO: consider whether to draw here? + # maybe make it toggleable a la draw_on + self._fig.canvas.draw() + + def activate_all(self): + for b in self._buttons: + b.state = True + if self.draw_on: + self._fig.canvas.draw() + + def set_states(self, states): + """ + Update the "buttons" to + Parameters + ---------- + toggled : dict or list + mapping i -> True/False. Does need to include states for every button. + """ + if isinstance(states, dict): + enum = states.items() + else: + enum = enumerate(states) + for i, s in enum: + self._buttons[i].state = s + + def get_states(self): + """ + Get the states of all buttons as a list + """ + states = [] + for button in self._buttons: + states.append(button.state) + return states + + @contextlib.contextmanager + def no_callbacks(self): + with self._observers.deactivate(): + yield diff --git a/readthedocs.yml b/readthedocs.yml new file mode 100644 index 0000000..7e525a1 --- /dev/null +++ b/readthedocs.yml @@ -0,0 +1,12 @@ +version: 2 +python: + version: 3.8 + install: + - method: pip + path: . + extra_requirements: + - doc + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py diff --git a/setup.cfg b/setup.cfg index 12ace94..67eb25e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,6 +17,7 @@ classifiers = Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 + Programming Language :: Python :: Implementation :: CPython project_urls = Source Code =https://github.com/ianhi/mpl-image-labeller @@ -39,6 +40,16 @@ dev = pre-commit pydocstyle pytest +doc = + Sphinx>=1.5 + jupyter-sphinx + myst-nb + numpydoc + sphinx-book-theme + sphinx-copybutton + sphinx-panels + sphinx-thebe + sphinx-togglebutton testing = pytest pytest-cov diff --git a/tox.ini b/tox.ini index 0c6805d..dcce437 100644 --- a/tox.ini +++ b/tox.ini @@ -1,28 +1,59 @@ +# https://github.com/ComPWA/ampform/blob/2ad1f7d14dc9bb58045a0fa83af4a353232dc5a6/tox.ini [tox] -envlist = py{37,38,39}-{linux,macos,windows} -toxworkdir=/tmp/.tox +envlist = + py, + doc, + sty, +passenv = PYTHONPATH +skip_install = True +skip_missing_interpreters = True +skipsdist = True -[gh-actions] -python = - 3.7: py37 - 3.8: py38 - 3.9: py39 +[testenv] +description = + Run all unit tests +allowlist_externals = + pytest +commands = + pytest {posargs} + +[testenv:doc] +description = + Build documentation and API through Sphinx +changedir = docs +allowlist_externals = + make +commands = + make html -[gh-actions:env] -PLATFORM = - ubuntu-latest: linux - macos-latest: macos - windows-latest: windows +[testenv:doclive] +description = + Set up a server to directly preview changes to the HTML pages +allowlist_externals = + sphinx-autobuild +passenv = + EXECUTE_NB + TERM +commands = + sphinx-autobuild \ + --watch docs \ + --watch mpl_image_labeller \ + --re-ignore .*/.ipynb_checkpoints/.* \ + --re-ignore .*/__pycache__/.* \ + --re-ignore docs/_build/.* \ + --re-ignore docs/api/.* \ + --re-ignore docs/examples/.*.gif \ + --re-ignore docs/gallery/.* \ + --open-browser \ + docs/ docs/_build/html -[testenv] -platform = - macos: darwin - linux: linux - windows: win32 -passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY +[testenv:docnb] +description = + Build documentation through Sphinx WITH output of Jupyter notebooks setenv = - PYTHONPATH = {toxinidir} -extras = - testing + EXECUTE_NB = "yes" +changedir = docs +allowlist_externals = + make commands = - pytest -v --color=yes --basetemp={envtmpdir} {posargs} + make html