diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d4a2c44 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 +end_of_line = lf + +[*.bat] +indent_style = tab +end_of_line = crlf + +[LICENSE] +insert_final_newline = false + +[Makefile] +indent_style = tab diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..a9626d9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,15 @@ +* picasso version: +* Python version: +* Operating System: + +### Description + +Describe what you were trying to get done. +Tell us what happened, what went wrong, and what you expected to happen. + +### What I Did + +``` +Paste the command(s) you ran and the output. +If there was a crash, please include the traceback here. +``` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dd7cb0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ +docs/source/ + +# PyBuilder +target/ + +# pyenv python configuration file +.python-version + +# Pycharm & Windows +.idea +data-volume +venv +*.whl +Dockerfile diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..8e518ee --- /dev/null +++ b/.travis.yml @@ -0,0 +1,46 @@ +# Config file for automatic testing at travis-ci.org +# This file will be regenerated if you run travis_pypi_setup.py + +dist: trusty +language: python +python: "3.5" + +# command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors +install: + # code below is taken from http://conda.pydata.org/docs/travis.html + # We do this conditionally because it saves us some downloading if the + # version is the same. + - if [[ "$TRAVIS_PYTHON_VERSION" == "2.7" ]]; then + wget https://repo.continuum.io/miniconda/Miniconda-latest-Linux-x86_64.sh -O miniconda.sh; + else + wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh; + fi + - bash miniconda.sh -b -p $HOME/miniconda + - export PATH="$HOME/miniconda/bin:$PATH" + - hash -r + - conda config --set always_yes yes --set changeps1 no + - conda update -q conda + # Useful for debugging any issues with conda + - conda info -a + + - conda create -q -n test-environment python=$TRAVIS_PYTHON_VERSION numpy scipy matplotlib pandas pytest h5py + - source activate test-environment + + # install PIL for preprocessing tests + - if [[ "$TRAVIS_PYTHON_VERSION" == "2.7" ]]; then + conda install pil; + elif [[ "$TRAVIS_PYTHON_VERSION" == "3.5" ]]; then + conda install Pillow; + fi + + - pip install --user codecov + - pip install pytest-cov + - pip install -e .[test] + + # install TensorFlow (CPU version). + - pip install tensorflow + +script: pytest --cov=picasso + +after_success: + - codecov diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 0000000..bbe280b --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,13 @@ +======= +Credits +======= + +Development Lead +---------------- + +* Ryan Henderson + +Contributors +------------ + +None yet. Why not be the first? diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..1b35ffc --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,57 @@ +============ +Contributing +============ + +Contributions are welcome, and they are greatly appreciated! Every +little bit helps, and credit will always be given. + +All contributors (submitting code) must agree to the `Eclipse Foundation Contributor License Agreement`_. We can instruct you how to do this in your pull request. + +You can contribute in many ways: + +Types of Contributions +---------------------- + +Report Bugs +~~~~~~~~~~~ + +Report bugs at https://github.com/merantix/picasso/issues. + +If you are reporting a bug, please include: + +* Your operating system name and version. +* Any details about your local setup that might be helpful in troubleshooting. +* Detailed steps to reproduce the bug. + +Fix Bugs +~~~~~~~~ + +Look through the GitHub issues for bugs. Anything tagged with "bug" +and "help wanted" is open to whoever wants to implement it. + +Implement Features +~~~~~~~~~~~~~~~~~~ + +Look through the GitHub issues for features. Anything tagged with "enhancement" +and "help wanted" is open to whoever wants to implement it. + +Write Documentation +~~~~~~~~~~~~~~~~~~~ + +picasso could always use more documentation, whether as part of the +official picasso docs, in docstrings, or even on the web in blog posts, +articles, and such. + +Submit Feedback +~~~~~~~~~~~~~~~ + +The best way to send feedback is to file an issue at https://github.com/merantix/picasso/issues. + +If you are proposing a feature: + +* Explain in detail how it would work. +* Keep the scope as narrow as possible, to make it easier to implement. +* Remember that this is a volunteer-driven project, and that contributions + are welcome :) + + .. _`Eclipse Foundation Contributor License Agreement`: https://eclipse.org/legal/CLA.php diff --git a/HISTORY.rst b/HISTORY.rst new file mode 100644 index 0000000..c188085 --- /dev/null +++ b/HISTORY.rst @@ -0,0 +1,8 @@ +======= +History +======= + +0.1.0 (2017-02-06) +------------------ + +* First release on PyPI. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f735bee --- /dev/null +++ b/LICENSE @@ -0,0 +1,203 @@ +Eclipse Public License - v 1.0 + +THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC +LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM +CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + +a) in the case of the initial Contributor, the initial code and documentation + distributed under this Agreement, and +b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + + where such changes and/or additions to the Program originate from and are + distributed by that particular Contributor. A Contribution 'originates' + from a Contributor if it was added to the Program by such Contributor + itself or anyone acting on such Contributor's behalf. Contributions do not + include additions to the Program which: (i) are separate modules of + software distributed in conjunction with the Program under their own + license agreement, and (ii) are not derivative works of the Program. + +"Contributor" means any person or entity that distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which are +necessarily infringed by the use or sale of its Contribution alone or when +combined with the Program. + +"Program" means the Contributions distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement, +including all Contributors. + +2. GRANT OF RIGHTS + a) Subject to the terms of this Agreement, each Contributor hereby grants + Recipient a non-exclusive, worldwide, royalty-free copyright license to + reproduce, prepare derivative works of, publicly display, publicly + perform, distribute and sublicense the Contribution of such Contributor, + if any, and such derivative works, in source code and object code form. + b) Subject to the terms of this Agreement, each Contributor hereby grants + Recipient a non-exclusive, worldwide, royalty-free patent license under + Licensed Patents to make, use, sell, offer to sell, import and otherwise + transfer the Contribution of such Contributor, if any, in source code and + object code form. This patent license shall apply to the combination of + the Contribution and the Program if, at the time the Contribution is + added by the Contributor, such addition of the Contribution causes such + combination to be covered by the Licensed Patents. The patent license + shall not apply to any other combinations which include the Contribution. + No hardware per se is licensed hereunder. + c) Recipient understands that although each Contributor grants the licenses + to its Contributions set forth herein, no assurances are provided by any + Contributor that the Program does not infringe the patent or other + intellectual property rights of any other entity. Each Contributor + disclaims any liability to Recipient for claims brought by any other + entity based on infringement of intellectual property rights or + otherwise. As a condition to exercising the rights and licenses granted + hereunder, each Recipient hereby assumes sole responsibility to secure + any other intellectual property rights needed, if any. For example, if a + third party patent license is required to allow Recipient to distribute + the Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + d) Each Contributor represents that to its knowledge it has sufficient + copyright rights in its Contribution, if any, to grant the copyright + license set forth in this Agreement. + +3. REQUIREMENTS + +A Contributor may choose to distribute the Program in object code form under +its own license agreement, provided that: + + a) it complies with the terms and conditions of this Agreement; and + b) its license agreement: + i) effectively disclaims on behalf of all Contributors all warranties + and conditions, express and implied, including warranties or + conditions of title and non-infringement, and implied warranties or + conditions of merchantability and fitness for a particular purpose; + ii) effectively excludes on behalf of all Contributors all liability for + damages, including direct, indirect, special, incidental and + consequential damages, such as lost profits; + iii) states that any provisions which differ from this Agreement are + offered by that Contributor alone and not by any other party; and + iv) states that source code for the Program is available from such + Contributor, and informs licensees how to obtain it in a reasonable + manner on or through a medium customarily used for software exchange. + +When the Program is made available in source code form: + + a) it must be made available under this Agreement; and + b) a copy of this Agreement must be included with each copy of the Program. + Contributors may not remove or alter any copyright notices contained + within the Program. + +Each Contributor must identify itself as the originator of its Contribution, +if +any, in a manner that reasonably allows subsequent Recipients to identify the +originator of the Contribution. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities with +respect to end users, business partners and the like. While this license is +intended to facilitate the commercial use of the Program, the Contributor who +includes the Program in a commercial product offering should do so in a manner +which does not create potential liability for other Contributors. Therefore, +if a Contributor includes the Program in a commercial product offering, such +Contributor ("Commercial Contributor") hereby agrees to defend and indemnify +every other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits and +other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such Commercial +Contributor in connection with its distribution of the Program in a commercial +product offering. The obligations in this section do not apply to any claims +or Losses relating to any actual or alleged intellectual property +infringement. In order to qualify, an Indemnified Contributor must: +a) promptly notify the Commercial Contributor in writing of such claim, and +b) allow the Commercial Contributor to control, and cooperate with the +Commercial Contributor in, the defense and any related settlement +negotiations. The Indemnified Contributor may participate in any such claim at +its own expense. + +For example, a Contributor might include the Program in a commercial product +offering, Product X. That Contributor is then a Commercial Contributor. If +that Commercial Contributor then makes performance claims, or offers +warranties related to Product X, those performance claims and warranties are +such Commercial Contributor's responsibility alone. Under this section, the +Commercial Contributor would have to defend claims against the other +Contributors related to those performance claims and warranties, and if a +court requires any other Contributor to pay any damages as a result, the +Commercial Contributor must pay those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON AN +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR +IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Each +Recipient is solely responsible for determining the appropriateness of using +and distributing the Program and assumes all risks associated with its +exercise of rights under this Agreement , including but not limited to the +risks and costs of program errors, compliance with applicable laws, damage to +or loss of data, programs or equipment, and unavailability or interruption of +operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY +CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION +LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of the +remainder of the terms of this Agreement, and without further action by the +parties hereto, such provision shall be reformed to the minimum extent +necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Program itself +(excluding combinations of the Program with other software or hardware) +infringes such Recipient's patent(s), then such Recipient's rights granted +under Section 2(b) shall terminate as of the date such litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it fails to +comply with any of the material terms or conditions of this Agreement and does +not cure such failure in a reasonable period of time after becoming aware of +such noncompliance. If all Recipient's rights under this Agreement terminate, +Recipient agrees to cease use and distribution of the Program as soon as +reasonably practicable. However, Recipient's obligations under this Agreement +and any licenses granted by Recipient relating to the Program shall continue +and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, but in +order to avoid inconsistency the Agreement is copyrighted and may only be +modified in the following manner. The Agreement Steward reserves the right to +publish new versions (including revisions) of this Agreement from time to +time. No one other than the Agreement Steward has the right to modify this +Agreement. The Eclipse Foundation is the initial Agreement Steward. The +Eclipse Foundation may assign the responsibility to serve as the Agreement +Steward to a suitable separate entity. Each new version of the Agreement will +be given a distinguishing version number. The Program (including +Contributions) may always be distributed subject to the version of the +Agreement under which it was received. In addition, after a new version of the +Agreement is published, Contributor may elect to distribute the Program +(including its Contributions) under the new version. Except as expressly +stated in Sections 2(a) and 2(b) above, Recipient receives no rights or +licenses to the intellectual property of any Contributor under this Agreement, +whether expressly, by implication, estoppel or otherwise. All rights in the +Program not expressly granted under this Agreement are reserved. + +This Agreement is governed by the laws of the State of New York and the +intellectual property laws of the United States of America. No party to this +Agreement will bring a legal action under this Agreement more than one year +after the cause of action arose. Each party waives its rights to a jury trial in +any resulting litigation. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0594a99 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,16 @@ + +include AUTHORS.rst + +include CONTRIBUTING.rst +include HISTORY.rst +include LICENSE +include README.rst + +recursive-include picasso/examples * +recursive-include picasso/templates * +recursive-include picasso/static * +recursive-include picasso/tests * +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] + +recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cd21998 --- /dev/null +++ b/Makefile @@ -0,0 +1,88 @@ +.PHONY: clean clean-test clean-pyc clean-build docs help +.DEFAULT_GOAL := help +define BROWSER_PYSCRIPT +import os, webbrowser, sys +try: + from urllib import pathname2url +except: + from urllib.request import pathname2url + +webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) +endef +export BROWSER_PYSCRIPT + +define PRINT_HELP_PYSCRIPT +import re, sys + +for line in sys.stdin: + match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) + if match: + target, help = match.groups() + print("%-20s %s" % (target, help)) +endef +export PRINT_HELP_PYSCRIPT +BROWSER := python -c "$$BROWSER_PYSCRIPT" + +help: + @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) + +clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts + + +clean-build: ## remove build artifacts + rm -fr build/ + rm -fr dist/ + rm -fr .eggs/ + find . -name '*.egg-info' -exec rm -fr {} + + find . -name '*.egg' -exec rm -f {} + + +clean-pyc: ## remove Python file artifacts + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + find . -name '__pycache__' -exec rm -fr {} + + +clean-test: ## remove test and coverage artifacts + rm -fr .tox/ + rm -f .coverage + rm -fr htmlcov/ + +lint: ## check style with flake8 + flake8 picasso tests + +test: ## run tests quickly with the default Python + py.test + + +test-all: ## run tests on every Python version with tox + tox + +coverage: ## check code coverage quickly with the default Python + coverage run --source picasso -m pytest + + coverage report -m + coverage html + $(BROWSER) htmlcov/index.html + +docs: ## generate Sphinx HTML documentation, including API docs + rm -f docs/picasso.rst + rm -f docs/modules.rst + sphinx-apidoc -o docs/ picasso + $(MAKE) -C docs clean + $(MAKE) -C docs html + $(BROWSER) docs/_build/html/index.html + +servedocs: docs ## compile the docs watching for changes + watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . + +release: clean ## package and upload a release + python setup.py sdist upload + python setup.py bdist_wheel upload + +dist: clean ## builds source and wheel package + python setup.py sdist + python setup.py bdist_wheel + ls -l dist + +install: clean ## install the package to the active Python's site-packages + python setup.py install diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..62f46fa --- /dev/null +++ b/README.rst @@ -0,0 +1,108 @@ +=============================== +Picasso +=============================== + + +.. image:: https://img.shields.io/pypi/v/picasso-viz.svg + :target: https://pypi.python.org/pypi/picasso-viz + +.. image:: https://img.shields.io/travis/merantix/picasso.svg + :target: https://travis-ci.org/merantix/picasso + +.. image:: https://readthedocs.org/projects/picasso/badge/?version=latest + :target: https://picasso.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + +.. image:: https://img.shields.io/codecov/c/github/merantix/picasso/master.svg + :target: https://codecov.io/github/merantix/picasso?branch=master + + +A CNN model visualizer + +See the `Medium post`_ for an introduction to Picasso. + +* Free software: Eclipse Public License +* Documentation: https://picasso.readthedocs.io. + + +Quickstart +---------- + +Picasso uses **Python 3.5+** so use a virtual environment if necessary (e.g. ``virtualenv env --python=python3``) and **activate it!** + +#. Install with pip or from source. + + With pip: + + .. code:: + + pip install picasso-viz + + From the repository: + + .. code:: + + git clone git@github.com:merantix/picasso.git + cd picasso + pip install -e . + + Note: you'll need the Tensorflow backend for Keras for these examples to work. Make sure your ``~/.keras/keras.json`` file looks like: + + .. code:: + + { + "backend": "tensorflow", + "image_dim_ordering": "tf", + "floatx": "float32", + "epsilon": 1e-07 + } + +#. Start the Flask server + + .. code:: + + export FLASK_APP=picasso + flask run + + Point your browser to ``127.0.0.1:5000`` and you should see the landing page! When you're done, ``Ctrl+C`` in the terminal to kill your Flask server. + +Building the docs +----------------- + +The documentation is much more extensive than this README, and includes instructions on getting the Keras VGG16 and Tensorflow NMIST models working, as well as guides on building your own visualizations and using custom models. This assumes you've cloned the repository. First install the required packages: + +.. code:: + + pip install -e .[docs] + +Then build them: + +.. code:: + + cd docs/ + make html + +Then you can open ``_build/html/index.html`` in your browser of choice. + +Notes +--------- +#. Models generated on Keras using the Theano backend should in principle be supported. The only difference is the array ordering of convolutions. I haven't tried this yet though, so an extra config parameter may be needed. + +Credits +--------- +* Elias_ and Filippo_ for early code contributions and finding bugs and issues. +* John_, Josh_, Rasmus_, and Stefan_ for their careful code review and feedback. +* The favicon is a modification of this photograph_ of the painting "`Les Demoiselles d'Avignon`_", 1907 by Pablo Picasso. Photograph by Max Braun. +* This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. + +.. _Cookiecutter: https://github.com/audreyr/cookiecutter +.. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage +.. _photograph: https://www.flickr.com/photos/maxbraun/4045020694 +.. _`Les Demoiselles d'Avignon`: https://en.wikipedia.org/wiki/Les_Demoiselles_d%27Avignon +.. _Elias: https://github.com/Sylvus +.. _Filippo: https://github.com/scopelf +.. _John: https://github.com/JohnMcSpedon +.. _Josh: https://github.com/jwayne +.. _Rasmus: https://github.com/rrothe +.. _Stefan: https://github.com/knub +.. _`Medium post`: https://medium.com/merantix/picasso-a-free-open-source-visualizer-for-cnns-d8ed3a35cfc5 diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..187a4a7 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,32 @@ +codecov: + branch: master # the branch to show by default + bot: rhsimplex + +coverage: + precision: 2 + round: down + range: "70...100" + + status: + project: + default: + target: auto + if_no_uploads: error + + patch: + default: + target: "80%" + if_no_uploads: error + + ignore: # files and folders that will be removed during processing + - "docs/*" + - "tests/*" + - "examples/*" + - "static/*" + - "templates/*" + +comment: + # @stevepeak (from codecov.io) suggested we change 'suggestions' to 'uncovered' + # in the following line. Thanks Steve! + layout: "header, diff, changes, sunburst, uncovered" + behavior: default diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..7b95e97 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,179 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build +MODULEDIR = source + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + rm -rf $(MODULEDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/picasso.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/picasso.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/picasso" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/picasso" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/_static/basic_vis.png b/docs/_static/basic_vis.png new file mode 100644 index 0000000..2a60b62 Binary files /dev/null and b/docs/_static/basic_vis.png differ diff --git a/docs/_static/menu.png b/docs/_static/menu.png new file mode 100644 index 0000000..70c1784 Binary files /dev/null and b/docs/_static/menu.png differ diff --git a/docs/_static/result_nohtml.png b/docs/_static/result_nohtml.png new file mode 100644 index 0000000..f027477 Binary files /dev/null and b/docs/_static/result_nohtml.png differ diff --git a/docs/_static/setting.png b/docs/_static/setting.png new file mode 100644 index 0000000..7548edb Binary files /dev/null and b/docs/_static/setting.png differ diff --git a/docs/_static/with_settings.png b/docs/_static/with_settings.png new file mode 100644 index 0000000..6268757 Binary files /dev/null and b/docs/_static/with_settings.png differ diff --git a/docs/authors.rst b/docs/authors.rst new file mode 100644 index 0000000..e122f91 --- /dev/null +++ b/docs/authors.rst @@ -0,0 +1 @@ +.. include:: ../AUTHORS.rst diff --git a/docs/conf.py b/docs/conf.py new file mode 100755 index 0000000..15a0c67 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# picasso documentation build configuration file, created by +# sphinx-quickstart on Tue Jul 9 22:26:36 2013. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os +import sphinx_rtd_theme + +# 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. +#sys.path.insert(0, os.path.abspath('.')) + +# Get the project root dir, which is the parent dir of this +cwd = os.getcwd() +project_root = os.path.dirname(cwd) + +# Insert the project root dir as the first element in the PYTHONPATH. +# This lets us ensure that the source package is imported, and that its +# version is used. +sys.path.insert(0, project_root) + +# -- General configuration --------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', 'sphinxcontrib.napoleon', 'sphinx.ext.todo'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'picasso' +copyright = u"2017, Ryan Henderson" + +# The version info for the project you're documenting, acts as replacement +# for |version| and |release|, also used in various other places throughout +# the built documents. +# +# The short X.Y version. +version = 'v0.1.1' +# The full version, including alpha/beta/rc tags. +release = 'v0.1.1' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to +# some non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built +# documents. +#keep_warnings = False + + +# -- Options for HTML output ------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'sphinx_rtd_theme' +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +# Theme options are theme-specific and customize the look and feel of a +# theme further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as +# html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the +# top of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon +# of the docs. This file should be a Windows icon file (.ico) being +# 16x16 or 32x32 pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) +# here, relative to this directory. They are copied after the builtin +# static files, so a file named "default.css" will overwrite the builtin +# "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page +# bottom, using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names +# to template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. +# Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. +# Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages +# will contain a tag referring to it. The value of this option +# must be the base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'picassodoc' + + +# -- Options for LaTeX output ------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + #'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass +# [howto/manual]). +latex_documents = [ + ('index', 'picasso.tex', + u'picasso Documentation', + u'Ryan Henderson', 'manual'), +] + +# The name of an image file (relative to this directory) to place at +# the top of the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings +# are parts, not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output ------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'picasso', + u'picasso Documentation', + [u'Ryan Henderson'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ---------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'picasso', + u'picasso Documentation', + u'Ryan Henderson', + 'picasso', + 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + + +def skip(app, what, name, obj, skip, options): + if name == "__init__": + return False + return skip + + +# See https://github.com/rtfd/readthedocs.org/issues/1139 +def run_apidoc(_): + import subprocess + module = '../picasso' + cur_dir = os.path.abspath(os.path.dirname(__file__)) + output_path = os.path.join(cur_dir, 'source') + cmd_path = 'sphinx-apidoc' + if hasattr(sys, 'real_prefix'): # Check to see if we are in a virtualenv + # If we are, assemble the path manually + cmd_path = os.path.abspath(os.path.join(sys.prefix, + 'bin', 'sphinx-apidoc')) + subprocess.check_call([cmd_path, '-f', '-o', + output_path, module, '--force']) + +def setup(app): + app.connect('builder-inited', run_apidoc) + app.connect("autodoc-skip-member", skip) diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..e582053 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1 @@ +.. include:: ../CONTRIBUTING.rst diff --git a/docs/history.rst b/docs/history.rst new file mode 100644 index 0000000..2506499 --- /dev/null +++ b/docs/history.rst @@ -0,0 +1 @@ +.. include:: ../HISTORY.rst diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..4eeffa1 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,23 @@ +Welcome to Picasso's documentation! +=============================================== + +Contents: + +.. toctree:: + :maxdepth: 2 + + Getting Started + visualizations + models + settings + Modules + contributing + authors + history + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..d98bce4 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,242 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +%SPHINXBUILD% 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 +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\picasso.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\picasso.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/docs/models.rst b/docs/models.rst new file mode 100644 index 0000000..8dd54ea --- /dev/null +++ b/docs/models.rst @@ -0,0 +1,112 @@ +=============================== +Using your own models +=============================== + +We include three `examples`_ for you to try: a model trained on the `MNIST`_ dataset for both Keras and Tensorflow, and a Keras `VGG16`_ model. We've tried to make it as simple as possible, but we do make a few assumptions: + +#. Your graph has a definitive entry and exit point. Specifically, a placeholder tensor for some kind of input and output operation (typically image arrays and class probabilities, respectively). + +#. These placeholders are of unspecified length. + +#. The portion of the computational graph you're interested in requires no other inputs. + +If you built your model with Keras using a `Sequential`_ model, you should be more or less good to go. If you used Tensorflow, you'll need to manually specify the entry and exit points [#]_. + +You can specify the backend (Tensorflow or Keras) using the ``PICASSO_BACKEND_ML`` setting. The allowed values are ``tensorflow`` or ``keras`` (see :doc:`settings`). + +Your model data +=============== + +You can specify the data directory with the ``PICASSO_DATA_DIR`` setting. This directory should contain the Keras or Tensorflow checkpoint files. If multiple checkpoints are found, the latest one will be used (see example `Keras model code`_). + +Utility functions +================= + +In addition to the graph and weight information of the model itself, you'll need to define a few functions to help the visualization interact with user input, and interpret raw output from your computational graph. These are arbitrary python functions, and their locations can be specified in the :doc:`settings`. + +We'll draw from the `Keras MNIST example`_ for this guide. + +Preprocessor +------------ + +The preprocessor takes images uploaded to the webapp and converts them into arrays that can be used as inputs to your model. The Flask app will haved converted them to `PIL Image`_ objects. + +.. code-block:: python3 + + MNIST_DIM = (28, 28) + + def preprocess(targets): + image_arrays = [] + for target in targets: + im = target.convert('L') + im = im.resize(MNIST_DIM, Image.ANTIALIAS) + arr = np.array(im) + image_arrays.append(arr) + + all_targets = np.array(image_arrays) + return all_targets.reshape(len(all_targets), + MNIST_DIM[0], + MNIST_DIM[1], 1).astype('float32') / 255 + +Specifically, we have to convert an arbitrary input color image to a float array of the input size specified with ``MNIST_DIM``. + +Postprocessor +------------- + +For some visualizations, it's useful to convert a flat representation back into an array with the same shape as the original image. + +.. code-block:: python3 + + def postprocess(output_arr): + images = [] + for row in output_arr: + im_array = row.reshape(MNIST_DIM) + images.append(im_array) + + return images + +This therefore takes an arbitrary array (with the same number of total entries as the image array) and reshapes it back. + +Class Decoder +------------- + +Class probabilities are usually returned in an array. For any visualization where we use classification, it's much nicer to have the class labels available. This method simply attaches the labels to computed probabilities. + +.. code-block:: python3 + + def prob_decode(probability_array, top=5): + results = [] + for row in probability_array: + entries = [] + for i, prob in enumerate(row): + entries.append({'index': i, + 'name': str(i), + 'prob': prob}) + + entries = sorted(entries, + key=itemgetter('prob'), + reverse=True)[:top] + + for entry in entries: + entry['prob'] = '{:.3f}'.format(entry['prob']) + results.append(entries) + + return results + +``results`` is then a list of dicts in the format ``[{'index': class_index, 'name': class_name, 'prob': class_probability}, ...]``. In the case of the MNIST dataset, the index is the same as the class name (digits 0-9). + +.. _examples: https://github.com/merantix/picasso/tree/master/picasso/examples + +.. _MNIST: http://yann.lecun.com/exdb/mnist/ + +.. _VGG16: http://www.robots.ox.ac.uk/~vgg/research/very_deep/ + +.. _Sequential: https://keras.io/models/sequential/ + +.. _Keras model code: https://github.com/merantix/picasso/blob/master/picasso/ml_frameworks/keras/model.py + +.. _Keras MNIST example: https://github.com/merantix/picasso/blob/master/picasso/examples/keras/util.py + +.. _PIL Image: http://pillow.readthedocs.io/en/latest/reference/Image.html + +.. [#] We hope to remove these limitations in the future to accomodate a wider variety of possible graph topologies while still maintaining separation between the visualization and model implementation as much as possible. diff --git a/docs/readme.rst b/docs/readme.rst new file mode 100644 index 0000000..3d87088 --- /dev/null +++ b/docs/readme.rst @@ -0,0 +1,142 @@ +=============================== +Picasso +=============================== + +A CNN model visualizer + + +* Free software: Eclipse Public License +* Documentation: https://picasso.readthedocs.io. + + +Quickstart +---------- + +Picasso uses **Python 3.5+** so use a virtual environment if necessary (e.g. ``virtualenv env --python=python3``) and **activate it!** + +#. Install with pip or from source. + + With pip: + + .. code:: + + pip install picasso-viz + + From the repository: + + .. code:: + + git clone git@github.com:merantix/picasso.git + cd picasso + pip install -e . + + Note: you'll need the Tensorflow backend for Keras for these examples to work. Make sure your ``~/.keras/keras.json`` file looks like: + + .. code:: + + { + "backend": "tensorflow", + "image_dim_ordering": "tf", + "floatx": "float32", + "epsilon": 1e-07 + } + +#. Optional (untested!): install Tensorflow with GPU support + + .. code:: + + pip uninstall tensorflow + pip install --upgrade tensorflow-gpu + +#. Start the Flask server + + .. code:: + + export FLASK_APP=picasso + flask run + + Point your browser to ``127.0.0.1:5000`` and you should see the landing page! When you're done, ``Ctrl+C`` in the terminal to kill your Flask server. + +#. By default, the visualizer starts a Keras MNIST example. We've also included a Keras VGG16 example. To use, it you'll need to get the VGG16 graph and weights. We've included a small script to do this. + + #. Setup VGG16: + + .. code:: + + python picasso/examples/keras-vgg16/prepare_model.py + + NOTE: if you installed with ``pip``, you'll need to find the location of this file in the site packages. ``pip show picasso_viz`` will tell you the location. For instance, if ``pip show picasso_viz`` shows you ``/home/ryan/test/env/lib/python3.5/site-packages``, then the above command should be: + + .. code:: + + python /home/ryan/test/env/lib/python3.5/site-packages/picasso/examples/keras-vgg16/prepare_model.py + + If this script fails, you might be behind a proxy. You can download the necessary files manually. + + .. code:: + + mkdir ~/.keras/models # If directory doesn't exist + wget --no-check-certificate -P ~/.keras/models/ https://github.com/fchollet/deep-learning-models/releases/download/v0.1/vgg16_weights_tf_dim_ordering_tf_kernels.h5 + wget --no-check-certificate -P ~/.keras/models/ https://s3.amazonaws.com/deep-learning-models/image-models/imagenet_class_index.json + + Run the script again, and you should be good to go! + + #. Point to the correct configuration (making sure to use the correct path to your directory): + + .. code:: + + export PICASSO_SETTINGS=/absolute/path/to/repo/picasso/picasso/examples/keras-vgg16/config.py + + Again, if you installed with ``pip install picasso-viz``, this will look something like: + + .. code:: + + export PICASSO_SETTINGS=/home/ryan/test/env/lib/python3.5/site-packages/picasso/examples/keras-vgg16/config.py + + You can check the ``pip show picasso_viz`` command for the base directory. + + #. Start Flask ``flask run``. If it worked, the "Current checkpoint" label should have changed on the landing page. + +Building the docs +----------------- +Assuming you've cloned the repository, install the required packages: + + .. code:: + + pip install -e .[docs] + +Then build them: + + .. code:: + + cd docs/ + make html + +Then you can open ``_build/html/index.html`` in your browser of choice. + +Running the tests +----------------- +Install the test requirements: + + .. code:: + + pip install -e .[test] + +Then run with: + + .. code:: + + py.test + +Notes +----- +#. This should be considered alpha software. You will encounter bugs and issues. Don't deploy this to a live server, probably... +#. Models generated on Keras using the Theano backend should in principle be supported. The only difference is the array ordering of convolutions. I haven't tried this yet though, so an extra config parameter may be needed. + +Credits +------- + +This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. + +.. _Cookiecutter: https://github.com/audreyr/cookiecutter +.. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..a0852ad --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +Sphinx +sphinxcontrib-napoleon +sphinx-rtd-theme diff --git a/docs/settings.rst b/docs/settings.rst new file mode 100644 index 0000000..e465a4c --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,49 @@ +======== +Settings +======== + +Application settings are `managed by Flask`_. This means you can use +environment variables or a configuration file. + +To specify your own configuration, use the ``PICASSO_SETTINGS`` +environment variable. Let's look at the Tensorflow MNIST example. + +.. code-block:: bash + + export PICASSO_SETTINGS=/absolute/path/to/repo/picasso/picasso/examples/tensorflow/config.py + +Tells the app to use this configuration instead of the default one. Inside +``config.py``, we have: + +.. code-block:: python3 + + import os + + base_dir = os.path.split(os.path.abspath(__file__))[0] + + BACKEND_ML = 'tensorflow' + BACKEND_PREPROCESSOR_NAME = 'util' + BACKEND_PREPROCESSOR_PATH = os.path.join(base_dir, 'util.py') + BACKEND_POSTPROCESSOR_NAME = 'postprocess' + BACKEND_POSTPROCESSOR_PATH = os.path.join(base_dir, 'util.py') + BACKEND_PROB_DECODER_NAME = 'prob_decode' + BACKEND_PROB_DECODER_PATH = os.path.join(base_dir, 'util.py') + DATA_DIR = os.path.join(base_dir, 'data-volume') + +Any lowercase line is ignored for the purposes of determining a setting. These +can also be set via environment variables, but you must append the app name. +For instance ``BACKEND_ML = 'tensorflow'`` would become ``export +PICASSO_BACKEND_ML=tensorflow``. + +For explanations of each setting, see :mod:`picasso.settings`. Any +additional settings starting with `BACKEND_` will be sent to the model backend +as a keyword argument. The input and output tensor names can be passed to the +Tensorflow backend in this way: + +.. code-block:: python3 + + ... + BACKEND_TF_PREDICT_VAR='Softmax:0' + BACKEND_TF_INPUT_VAR='convolution2d_input_1:0' + +.. _managed by Flask: http://flask.pocoo.org/docs/latest/config/ diff --git a/docs/visualizations.rst b/docs/visualizations.rst new file mode 100644 index 0000000..86d65c1 --- /dev/null +++ b/docs/visualizations.rst @@ -0,0 +1,406 @@ +=============================== +Making your own visualizations +=============================== + +Picasso is made with ease of adding new models in mind. This tutorial will show you how to make a new visualization from scratch. Our visualization will be based on the very simple :class:`~picasso.visualizations.class_probabilities.ClassProbabilities` (see `ClassProbabilities`_ code) visualization, along with its HTML `template`_. + +Setup +===== + +Every visualization requires a class defining its behavior and an HTML template defining its layout. You can put them in the ``visualizations`` and ``templates`` folder respectively. It's important that the class name and HTML template name are the same. + +For our example, ``FunViz``, we'll need ``picasso/visualizations/fun_viz.py``: + +.. code-block:: python3 + + from picasso.visualizations import BaseVisualization + + + class FunViz(BaseVisualization): + + def __init__(self, model): + self.description = 'A fun visualization!' + self.model = model + + def make_visualization(self, inputs, output_dir, settings=None): + pass + +and ``picasso/templates/FunViz.html``: + +.. code-block:: jinja + + {% extends "result.html" %} + {% block vis %} + your visualization html goes here + {% endblock %} + +Some explanation for the ``FunViz`` class in ``fun_viz.py``: All visualizations should inherit from :class:`~picasso.visualizations.__init__.BaseVisualization` (see `code `_). You must implement the ``__init__`` method, and it should accept one argument, ``model``. ``model`` will be an instance of a child class of `Model`_, which provides an interface to the machine learning backend. You can also add a description which will display on the landing page. + +Some explanation for ``FunViz.html``: The web app is uses `Flask`_, which uses `Jinja2`_ templating. This explains the funny ``{% %}`` delimiters. The ``{% extends "result.html" %}`` just tells the your page to inherit from a boilerplate. All your html should sit within the ``vis`` block. + +You can even start the app at this point (see :doc:`Quickstart `). You should see your visualization in the menu. + +.. figure:: _static/menu.png + :align: center + + Are we having fun yet? ☆(◒‿◒)☆ YES + +If you try to upload images, you will get an error. This is because the visualization doesn't actually return anything to visualize. Let's fix that. + +Add visualization logic +======================= + +Our visualization should actually do something. It's just going to compute the class probabilities and pass them back along to the web app. So we'll add: + +.. code-block:: python3 + :emphasize-lines: 11-21 + + from picasso.visualizations import BaseVisualization + + + class FunViz(BaseVisualization): + + def __init__(self, model): + self.description = 'A fun visualization!' + self.model = model + + def make_visualization(self, inputs, output_dir, settings=None): + pre_processed_arrays = self.model.preprocess([example['data'] + for example in inputs]) + predictions = self.model.sess.run(self.model.tf_predict_var, + feed_dict={self.model.tf_input_var: + pre_processed_arrays}) + filtered_predictions = self.model.decode_prob(predictions) + results = [] + for i, inp in enumerate(inputs): + results.append({'input_file_name': inp['filename'], + 'predict_probs': filtered_predictions[i]}) + return results + +Let's go line by line: + +.. code-block:: python3 + :emphasize-lines: 7,8 + + ... + + class FunViz(BaseVisualization): + ... + + def make_visualization(self, inputs, output_dir, settings=None): + pre_processed_arrays = self.model.preprocess([example['data'] + for example in inputs]) + ... + +``inputs`` are sent to the visualization class as a list of ``{'filename': ... , 'data': ...}`` dictionaries. The data are `PIL Images`_ created from raw data that the user has uploaded to the webapp. The ``preprocess`` method of ``model`` simply turns the input images into appropriately-sized arrays for the input of whichever computational graph you are using. Therefore, ``pre_processed_arrays`` is an array with the first dimension equal to the number of inputs, and subsequent dimensions determined by the ``preprocess`` function. + +.. code-block:: python3 + :emphasize-lines: 9-11 + + ... + + class FunViz(BaseVisualization): + ... + + def make_visualization(self, inputs, output_dir, settings=None): + pre_processed_arrays = self.model.preprocess([example['data'] + for example in inputs]) + predictions = self.model.sess.run(self.model.tf_predict_var, + feed_dict={self.model.tf_input_var: + pre_processed_arrays}) + ... + +Here's where we actually do some computation to be used in the visualization. Note that the ``model`` object exposes the Tensorflow session (regardless of if the backend is Keras or Tensorflow). We also store the input and output tensors with the ``model`` members ``tf_input_var`` and ``tf_predict_var`` respectively. Thus this is just a standard Tensorflow run which will return an array of dimension ``n x c`` where ``n`` is the number of inputs, and ``c`` is the number of classes. + +.. code-block:: python3 + :emphasize-lines: 12 + + ... + + class FunViz(BaseVisualization): + ... + + def make_visualization(self, inputs, output_dir, settings=None): + pre_processed_arrays = self.model.preprocess([example['data'] + for example in inputs]) + predictions = self.model.sess.run(self.model.tf_predict_var, + feed_dict={self.model.tf_input_var: + pre_processed_arrays}) + filtered_predictions = self.model.decode_prob(predictions) + ... + +``decode_prob`` is another model-specific method. It gives us back the class labels from the ``predictions`` array. The format will be list of dictionaries in the format ``[{'index': class_index, 'name': class_name, 'prob': class_probability}, ...]``. It will also only return the top class predictions (this comes in handy when using models like VGG16, which has 1000 classes). + +.. code-block:: python3 + :emphasize-lines: 13-17 + + ... + + class FunViz(BaseVisualization): + ... + + def make_visualization(self, inputs, output_dir, settings=None): + pre_processed_arrays = self.model.preprocess([example['data'] + for example in inputs]) + predictions = self.model.sess.run(self.model.tf_predict_var, + feed_dict={self.model.tf_input_var: + pre_processed_arrays}) + filtered_predictions = self.model.decode_prob(predictions) + results = [] + for i, inp in enumerate(inputs): + results.append({'input_file_name': inp['filename'], + 'predict_probs': filtered_predictions[i]}) + return results + +Here we arrange the results to pass back to the webapp. In our case, we just return a list of dictionaries which hold the original filename, and the formatted prediction results. The exact structure isn't so important, but you'll have to deal with it when you write your HTML template, so try to keep it manageable. Now you'll be able to see your result page from earlier. + +.. figure:: _static/result_nohtml.png + :align: center + + At least it's fast, right? + +Of course, we haven't told the template how to display the results yet. Let's get down to it. + +Configure the HTML template +=========================== + +We need to specify how to layout our visualization. Here are the lines we'll add: + +.. code-block:: jinja + :emphasize-lines: 3-20 + + {% extends "result.html" %} + {% block vis %} + + {% for result in results %} + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + {% endfor %} +
{{ result.filename }} {{ predict_prob.name }}
+ + {{ predict_prob.prob }}
+ {% endblock %} + +Let's look at the pieces separately again: + +.. code-block:: jinja + :emphasize-lines: 3,4,19,20 + + {% extends "result.html" %} + {% block vis %} + + {% for result in results %} + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + {% endfor %} +
{{ result.filename }} {{ predict_prob.name }}
+ + {{ predict_prob.prob }}
+ {% endblock %} + +Every visualization gets a ``results`` object from the web app. The ``results`` object will have the exact same structure as the return value of the ``make_visualization`` method of your visualization class. Since we returned a list, we iterate over it with this for-loop to generate the rows of the table. + +.. code-block:: jinja + :emphasize-lines: 5,10,11,18 + + {% extends "result.html" %} + {% block vis %} + + {% for result in results %} + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + {% endfor %} +
{{ result.filename }} {{ predict_prob.name }}
+ + {{ predict_prob.prob }}
+ {% endblock %} + +There are actually two rows per result. One with the filename and class labels, and one with the input image and class probabilities. Let's look at each in turn. + +.. code-block:: jinja + :emphasize-lines: 6-9 + + {% extends "result.html" %} + {% block vis %} + + {% for result in results %} + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + {% endfor %} +
{{ result.filename }} {{ predict_prob.name }}
+ + {{ predict_prob.prob }}
+ {% endblock %} + +The first column has the filename and the class name headers. The for-loop loops over the ``result.predict_prob`` list of predictions (which we generated in ``make_visualization``) and puts each class header in a cell. + +.. code-block:: jinja + :emphasize-lines: 12-17 + + {% extends "result.html" %} + {% block vis %} + + {% for result in results %} + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + {% endfor %} +
{{ result.filename }} {{ predict_prob.name }}
+ + {{ predict_prob.prob }}
+ {% endblock %} + +The second row contains the input image and the actual numerical probabilities. Note the ``inputs/`` in the ``img`` tag. All input images are stored here by the web app. + +.. figure:: _static/basic_vis.png + :align: center + + Sooo beautiful ⊂◉‿◉つ + +Similarly, there is an ``outputs/`` folder (not shown in this example). Its path is passed to the visualization class as ``output_dir``. Anything the visualization stores there is also available to the template (for example, additional images needed for the visualization). + +Add some settings +================= + +Maybe we'd like the user to be able to limit the number of classes shown. We can easily do this by adding a ``settings`` property to the ``FunViz`` class. + +.. code-block:: python3 + :emphasize-lines: 5, 21 + + from picasso.visualizations import BaseVisualization + + + class FunViz(BaseVisualization): + settings = {'Display': ['1', '2', '3']} + + def __init__(self, model): + self.description = 'A fun visualization!' + self.model = model + + def make_visualization(self, inputs, output_dir, settings=None): + pre_processed_arrays = self.model.preprocess([example['data'] + for example in inputs]) + predictions = self.model.sess.run(self.model.tf_predict_var, + feed_dict={self.model.tf_input_var: + pre_processed_arrays}) + filtered_predictions = self.model.decode_prob(predictions) + results = [] + for i, inp in enumerate(inputs): + results.append({'input_file_name': inp['filename'], + 'predict_probs': filtered_predictions[i][:int(settings['Display'])]}) + return results + +A page to select the settings will automatically be generated. + +.. figure:: _static/setting.png + :align: center + + The automatically generated settings page + +.. figure:: _static/with_settings.png + :align: center + + It works! ヽ(^◇^*)/ + +Add some styling +================ + +The template that ``FunViz.html`` derives from imports `Bootstrap`_, so you can add some fancier styling if you like! + +.. code-block:: jinja + + {% extends "result.html" %} + {% block vis %} + + + {% for result in results %} + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + {% endfor %} + +
{{ result.filename }} {{ predict_prob.name }}
+ + {{ predict_prob.prob }}
+ {% endblock %} + +Further Reading +=============== + +For more complex visualizations, see the examples in `the visualizations module`_. + +.. _ClassProbabilities: https://github.com/merantix/picasso/blob/master/picasso/visualizations/class_probabilities.py + +.. _template: https://github.com/merantix/picasso/blob/master/picasso/templates/ClassProbabilities.html + +.. _BaseVisualization: https://github.com/merantix/picasso/blob/master/picasso/visualizations/__init__.py + +.. _Model: https://github.com/merantix/picasso/blob/master/picasso/ml_frameworks/model.py + +.. _Flask: http://flask.pocoo.org/ + +.. _Jinja2: http://jinja.pocoo.org/docs/ + +.. _PIL Images: http://pillow.readthedocs.io/en/latest/reference/Image.html + +.. _Bootstrap: http://getbootstrap.com/ + +.. _the visualizations module: https://github.com/merantix/picasso/blob/master/picasso/visualizations/ diff --git a/picasso/__init__.py b/picasso/__init__.py new file mode 100644 index 0000000..32598a7 --- /dev/null +++ b/picasso/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +__author__ = """Ryan Henderson""" +__email__ = 'ryan@merantix.com' +__version__ = 'v0.1.1' + +from flask import Flask +import os +import sys + +if sys.version_info.major < 3 or (sys.version_info.major == 3 and + sys.version_info.minor < 5): + raise SystemError('Python 3.5+ required, found {}'.format(sys.version)) + +app = Flask(__name__) +app.config.from_object('picasso.settings.Default') + +if os.getenv('PICASSO_SETTINGS'): + app.config.from_envvar('PICASSO_SETTINGS') + +import picasso.picasso diff --git a/picasso/examples/keras-vgg16/config.py b/picasso/examples/keras-vgg16/config.py new file mode 100644 index 0000000..7ab3fa5 --- /dev/null +++ b/picasso/examples/keras-vgg16/config.py @@ -0,0 +1,12 @@ +import os + +base_dir = os.path.dirname(os.path.abspath(__file__)) + +BACKEND_ML = 'keras' +BACKEND_PREPROCESSOR_NAME = 'preprocess' +BACKEND_PREPROCESSOR_PATH = os.path.join(base_dir, 'util.py') +BACKEND_POSTPROCESSOR_NAME = 'postprocess' +BACKEND_POSTPROCESSOR_PATH = os.path.join(base_dir, 'util.py') +BACKEND_PROB_DECODER_NAME = 'prob_decode' +BACKEND_PROB_DECODER_PATH = os.path.join(base_dir, 'util.py') +DATA_DIR = os.path.join(base_dir, 'data-volume') diff --git a/picasso/examples/keras-vgg16/prepare_model.py b/picasso/examples/keras-vgg16/prepare_model.py new file mode 100644 index 0000000..c5815f0 --- /dev/null +++ b/picasso/examples/keras-vgg16/prepare_model.py @@ -0,0 +1,29 @@ +import os +import json +from keras.applications.vgg16 import VGG16 + +path = 'data-volume' +try: + os.mkdir(path) +except FileExistsError: + pass + +print('Downloading and setting up VGG16...') + +vgg16 = VGG16() + +print('Saving...') + +if not os.path.exists(os.path.join(os.path.dirname(__file__), path)): + os.makedirs(os.path.join(os.path.dirname(__file__), path)) + +with open(os.path.join(os.path.dirname(__file__), + path, + 'vgg16.json'), 'w') as json_file: + json.dump(vgg16.to_json(), json_file) + +vgg16.save_weights(os.path.join(os.path.dirname(__file__), + path, + 'vgg16.hdf5')) + +print('Done.') diff --git a/picasso/examples/keras-vgg16/util.py b/picasso/examples/keras-vgg16/util.py new file mode 100644 index 0000000..e56eaa0 --- /dev/null +++ b/picasso/examples/keras-vgg16/util.py @@ -0,0 +1,53 @@ +from keras.applications.imagenet_utils import (decode_predictions, + preprocess_input) +import keras.applications.imagenet_utils +from PIL import Image +import numpy as np + +VGG16_DIM = (224, 224, 3) + + +def preprocess(targets): + image_arrays = [] + for target in targets: + im = target.resize(VGG16_DIM[:2], Image.ANTIALIAS) + im = im.convert('RGB') + arr = np.array(im).astype('float32') + image_arrays.append(arr) + + all_targets = np.array(image_arrays) + return preprocess_input(all_targets) + + +def postprocess(output_arr): + images = [] + for row in output_arr: + im_array = row.reshape(VGG16_DIM[:2]) + images.append(im_array) + + return images + + +def prob_decode(probability_array, top=5): + r = decode_predictions(probability_array, top=top) + results = [ + [{'code': entry[0], + 'name': entry[1], + 'prob': '{:.3f}'.format(entry[2])} + for entry in row] + for row in r + ] + classes = keras.applications.imagenet_utils.CLASS_INDEX + class_keys = list(classes.keys()) + class_values = list(classes.values()) + + for result in results: + for entry in result: + entry.update( + {'index': + int( + class_keys[class_values.index([entry['code'], + entry['name']])] + )} + ) + return results diff --git a/picasso/examples/keras/config.py b/picasso/examples/keras/config.py new file mode 100644 index 0000000..0ecdf76 --- /dev/null +++ b/picasso/examples/keras/config.py @@ -0,0 +1,19 @@ +# Note: this settings file duplicates the default settings in the top-level +# file `settings.py`. If you want to modify settings here, you must export the +# path to this file: +# +# export PICASSO_SETTINGS=/path/to/picasso/picasso/examples/keras/config.py +# +# otherwise, these settings will not be loaded. +import os + +base_dir = os.path.dirname(os.path.abspath(__file__)) + +BACKEND_ML = 'keras' +BACKEND_PREPROCESSOR_NAME = 'preprocess' +BACKEND_PREPROCESSOR_PATH = os.path.join(base_dir, 'util.py') +BACKEND_POSTPROCESSOR_NAME = 'postprocess' +BACKEND_POSTPROCESSOR_PATH = os.path.join(base_dir, 'util.py') +BACKEND_PROB_DECODER_NAME = 'prob_decode' +BACKEND_PROB_DECODER_PATH = os.path.join(base_dir, 'util.py') +DATA_DIR = os.path.join(base_dir, 'data-volume') diff --git a/picasso/examples/keras/data-volume/MNIST-weights.hdf5 b/picasso/examples/keras/data-volume/MNIST-weights.hdf5 new file mode 100644 index 0000000..74de32e Binary files /dev/null and b/picasso/examples/keras/data-volume/MNIST-weights.hdf5 differ diff --git a/picasso/examples/keras/data-volume/model.json b/picasso/examples/keras/data-volume/model.json new file mode 100644 index 0000000..394e41e --- /dev/null +++ b/picasso/examples/keras/data-volume/model.json @@ -0,0 +1 @@ +"{\"class_name\": \"Sequential\", \"keras_version\": \"1.2.1\", \"config\": [{\"class_name\": \"Convolution2D\", \"config\": {\"b_regularizer\": null, \"W_constraint\": null, \"b_constraint\": null, \"name\": \"images\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"tf\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"glorot_uniform\", \"bias\": true, \"nb_filter\": 32, \"input_dtype\": \"float32\", \"border_mode\": \"valid\", \"batch_input_shape\": [null, 28, 28, 1], \"W_regularizer\": null, \"activation\": \"linear\", \"nb_row\": 3}}, {\"class_name\": \"Activation\", \"config\": {\"activation\": \"relu\", \"trainable\": true, \"name\": \"activation_1\"}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_1\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"tf\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"glorot_uniform\", \"bias\": true, \"nb_filter\": 32, \"border_mode\": \"valid\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"linear\", \"nb_row\": 3}}, {\"class_name\": \"Activation\", \"config\": {\"activation\": \"relu\", \"trainable\": true, \"name\": \"activation_2\"}}, {\"class_name\": \"MaxPooling2D\", \"config\": {\"name\": \"maxpooling2d_1\", \"trainable\": true, \"dim_ordering\": \"tf\", \"pool_size\": [2, 2], \"strides\": [2, 2], \"border_mode\": \"valid\"}}, {\"class_name\": \"Dropout\", \"config\": {\"p\": 0.25, \"trainable\": true, \"name\": \"dropout_1\"}}, {\"class_name\": \"Flatten\", \"config\": {\"trainable\": true, \"name\": \"flatten_1\"}}, {\"class_name\": \"Dense\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"dense_1\", \"activity_regularizer\": null, \"trainable\": true, \"init\": \"glorot_uniform\", \"bias\": true, \"input_dim\": 4608, \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"linear\", \"output_dim\": 128}}, {\"class_name\": \"Activation\", \"config\": {\"activation\": \"relu\", \"trainable\": true, \"name\": \"activation_3\"}}, {\"class_name\": \"Dropout\", \"config\": {\"p\": 0.5, \"trainable\": true, \"name\": \"dropout_2\"}}, {\"class_name\": \"Dense\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"logits\", \"activity_regularizer\": null, \"trainable\": true, \"init\": \"glorot_uniform\", \"bias\": true, \"input_dim\": 128, \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"linear\", \"output_dim\": 10}}, {\"class_name\": \"Activation\", \"config\": {\"activation\": \"softmax\", \"trainable\": true, \"name\": \"activation_4\"}}]}" \ No newline at end of file diff --git a/picasso/examples/keras/util.py b/picasso/examples/keras/util.py new file mode 100644 index 0000000..6f6202e --- /dev/null +++ b/picasso/examples/keras/util.py @@ -0,0 +1,90 @@ +from PIL import Image +from operator import itemgetter +import numpy as np + +MNIST_DIM = (28, 28) + + +def preprocess(targets): + """Turn images into computation inputs + + Converts an iterable of PIL Images into a suitably-sized numpy array which + can be used as an input to the evaluation portion of the Keras/tensorflow + graph. + + Args: + targets (list of Images): a list of PIL Image objects + + Returns: + array (float32) + + """ + image_arrays = [] + for target in targets: + im = target.convert('L') + im = im.resize(MNIST_DIM, Image.ANTIALIAS) + arr = np.array(im) + image_arrays.append(arr) + + all_targets = np.array(image_arrays) + return all_targets.reshape(len(all_targets), + MNIST_DIM[0], + MNIST_DIM[1], 1).astype('float32') / 255 + + +def postprocess(output_arr): + """Reshape arrays to original image dimensions + + Typically used for outputs or computations on intermediate layers which + make sense to represent as an image in the original dimension of the input + images (see ``SaliencyMaps``). + + Args: + output_arr (array of float32): Array of leading dimension n containing + n arrays to be reshaped + + Returns: + reshaped array + + """ + images = [] + for row in output_arr: + im_array = row.reshape(MNIST_DIM) + images.append(im_array) + + return images + + +def prob_decode(probability_array, top=5): + """Provide class information from output probabilities + + Gives the visualization additional context for the computed class + probabilities. + + Args: + probability_array (array): class probabilities + top (int): number of class entries to return. Useful for limiting + output in models with many classes. Defaults to 5. + + Returns: + result list of dict in the format [{'index': class_index, 'name': + class_name, 'prob': class_probability}, ...] + + """ + results = [] + for row in probability_array: + entries = [] + for i, prob in enumerate(row): + entries.append({'index': i, + 'name': str(i), + 'prob': prob}) + + entries = sorted(entries, + key=itemgetter('prob'), + reverse=True)[:top] + + for entry in entries: + entry['prob'] = '{:.3f}'.format(entry['prob']) + results.append(entries) + + return results diff --git a/picasso/examples/tensorflow/config.py b/picasso/examples/tensorflow/config.py new file mode 100644 index 0000000..60de7b0 --- /dev/null +++ b/picasso/examples/tensorflow/config.py @@ -0,0 +1,14 @@ +import os + +base_dir = os.path.dirname(os.path.abspath(__file__)) + +BACKEND_ML = 'tensorflow' +BACKEND_PREPROCESSOR_NAME = 'preprocess' +BACKEND_PREPROCESSOR_PATH = os.path.join(base_dir, 'util.py') +BACKEND_POSTPROCESSOR_NAME = 'postprocess' +BACKEND_POSTPROCESSOR_PATH = os.path.join(base_dir, 'util.py') +BACKEND_PROB_DECODER_NAME = 'prob_decode' +BACKEND_PROB_DECODER_PATH = os.path.join(base_dir, 'util.py') +BACKEND_TF_PREDICT_VAR = 'Softmax:0' +BACKEND_TF_INPUT_VAR = 'convolution2d_input_1:0' +DATA_DIR = os.path.join(base_dir, 'data-volume') diff --git a/picasso/examples/tensorflow/data-volume/checkpoint b/picasso/examples/tensorflow/data-volume/checkpoint new file mode 100644 index 0000000..e37193c --- /dev/null +++ b/picasso/examples/tensorflow/data-volume/checkpoint @@ -0,0 +1,2 @@ +model_checkpoint_path: "/tmp/convolutional.ckpt" +all_model_checkpoint_paths: "/tmp/convolutional.ckpt" diff --git a/picasso/examples/tensorflow/data-volume/convolutional.ckpt.data-00000-of-00001 b/picasso/examples/tensorflow/data-volume/convolutional.ckpt.data-00000-of-00001 new file mode 100644 index 0000000..ca24b6b Binary files /dev/null and b/picasso/examples/tensorflow/data-volume/convolutional.ckpt.data-00000-of-00001 differ diff --git a/picasso/examples/tensorflow/data-volume/convolutional.ckpt.index b/picasso/examples/tensorflow/data-volume/convolutional.ckpt.index new file mode 100644 index 0000000..a7ec312 Binary files /dev/null and b/picasso/examples/tensorflow/data-volume/convolutional.ckpt.index differ diff --git a/picasso/examples/tensorflow/data-volume/convolutional.ckpt.meta b/picasso/examples/tensorflow/data-volume/convolutional.ckpt.meta new file mode 100644 index 0000000..31ae2a4 Binary files /dev/null and b/picasso/examples/tensorflow/data-volume/convolutional.ckpt.meta differ diff --git a/picasso/examples/tensorflow/util.py b/picasso/examples/tensorflow/util.py new file mode 100644 index 0000000..1820184 --- /dev/null +++ b/picasso/examples/tensorflow/util.py @@ -0,0 +1,48 @@ +from PIL import Image +from operator import itemgetter +import numpy as np + +MNIST_DIM = (28, 28) + + +def preprocess(targets): + image_arrays = [] + for target in targets: + im = target.convert('L') + im = im.resize(MNIST_DIM, Image.ANTIALIAS) + arr = np.array(im) + image_arrays.append(arr) + + all_targets = np.array(image_arrays) + return all_targets.reshape(len(all_targets), + MNIST_DIM[0], + MNIST_DIM[1], 1).astype('float32') / 255 + + +def postprocess(output_arr): + images = [] + for row in output_arr: + im_array = row.reshape(MNIST_DIM) + images.append(im_array) + + return images + + +def prob_decode(probability_array, top=5): + results = [] + for row in probability_array: + entries = [] + for i, prob in enumerate(row): + entries.append({'index': i, + 'name': str(i), + 'prob': prob}) + + entries = sorted(entries, + key=itemgetter('prob'), + reverse=True)[:top] + + for entry in entries: + entry['prob'] = '{:.3f}'.format(entry['prob']) + results.append(entries) + + return results diff --git a/picasso/ml_frameworks/__init__.py b/picasso/ml_frameworks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/picasso/ml_frameworks/keras/__init__.py b/picasso/ml_frameworks/keras/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/picasso/ml_frameworks/keras/model.py b/picasso/ml_frameworks/keras/model.py new file mode 100644 index 0000000..5b6715c --- /dev/null +++ b/picasso/ml_frameworks/keras/model.py @@ -0,0 +1,72 @@ +import os +import glob +import json +from datetime import datetime + +import keras.backend as K +from keras.models import model_from_json + +from picasso.ml_frameworks.tensorflow.model import TFModel + + +class KerasModel(TFModel): + """Implements model loading functions for Keras + + Using this Keras module will require the h5py library, + which is not included with Keras + + Attributes: + sess (Tensorflow :obj:`Session`): underlying Tensorflow session of + the Keras model. + tf_predict_var (:obj:`Tensor`): tensorflow tensor which represents + the class probabilities + tf_input_var (:obj:`Tensor`): tensorflow tensor which represents + the inputs + + """ + + def load(self, data_dir='./'): + """Load graph and weight data + + Args: + data_dir (:obj:`str`): location of Keras checkpoint (`.hdf5`) files + and model (in `.json`) structure. The default behavior + is to take the latest of each, by OS timestamp. + + """ + + # find newest ckpt and graph files + try: + latest_ckpt = max(glob.iglob( + os.path.join(data_dir, '*.h*5')), + key=os.path.getctime) + + self.latest_ckpt_name = os.path.basename(latest_ckpt) + self.latest_ckpt_time = str(datetime.fromtimestamp( + os.path.getmtime(latest_ckpt)) + ) + + except ValueError: + raise FileNotFoundError('No checkpoint (.hdf5 or .h5) files ' + 'available at {}'.format(data_dir)) + try: + latest_json = max(glob.iglob(os.path.join(data_dir, '*.json')), + key=os.path.getctime) + except ValueError: + raise FileNotFoundError('No graph (.json) files ' + 'available at {}'.format(data_dir)) + + # for tensorflow compatibility + K.set_learning_phase(0) + with open(latest_json, 'r') as f: + model_json = json.loads(f.read()) + self.model = model_from_json(model_json) + + self.model.load_weights(latest_ckpt) + self.sess = K.get_session() + + self.tf_predict_var = self.model.outputs[0] + self.tf_input_var = self.model.inputs[0] + + def _predict(self, input_array): + return self.model.predict(input_array) diff --git a/picasso/ml_frameworks/model.py b/picasso/ml_frameworks/model.py new file mode 100644 index 0000000..4d687b1 --- /dev/null +++ b/picasso/ml_frameworks/model.py @@ -0,0 +1,237 @@ +import importlib.util +import warnings +from importlib import import_module +from operator import itemgetter + +ML_LIBRARIES = { + 'tensorflow': + 'picasso.ml_frameworks.tensorflow.model.TFModel', + 'keras': + 'picasso.ml_frameworks.keras.model.KerasModel' +} + + +class Model: + """Model class interface. + + All ML frameworks should derive from this class for the purposes of + the visualization. This class loads saved files generated by various + ML frameworks and allows us to extract the graph topology, weights, etc. + + """ + + def __init__(self, + preprocessor_name='preprocess', + preprocessor_path=None, + postprocessor_name='postprocess', + postprocessor_path=None, + prob_decoder_name='prob_decode', + prob_decoder_path=None, + top_probs=5, + **kwargs): + """Attempt to load utilities + + The class constructor attempts to import a preprocessor, postprocessor, + and probability decoder if a path is supplied. + + Args: + preprocessor_name (str, optional): the name of the preprocessing + function. Defaults to 'preprocess'. + preprocessor_path (str, optional): the absolute path to the file + containing the function named above. If `None`, then do not + try to load a preprocessor. Defaults to `None`. + postprocessor_name (str, optional): the name of the postprocessing + function. Defaults to 'postprocess'. + postprocessor_path (str, optional): the absolute path to the file + containing the function named above. If `None`, then do not + try to load a postprocessor. Defaults to `None`. + prob_decoder_name (str, optional): the name of the postprocessing + function. Defaults to 'prob_decode'. + prob_decoder_path (str, optional): the absolute path to the file + containing the function named above. If `None`, then do not + try to load a prob_decoder. Defaults to `None`. + top_probs (int): Number of classes to display per result. For + instance, VGG16 has 1000 classes, we don't want to display a + visualization for every single possibility. Defaults to 5. + **kwargs: Arbitrary keyword arguments, useful for passing specific + settings to derived classes. + + Example: + If you define a function called "preprocess" at "/path/to/util.py", + then try:: + + preprocessor_name='preprocess', + preprocessor_path='/path/to/util.py' + + """ + self.latest_ckpt_name = None + self.latest_ckpt_time = None + self.top_probs = top_probs + + self.preprocessor_name = preprocessor_name + self.preprocessor_path = preprocessor_path + self.postprocessor_name = postprocessor_name + self.postprocessor_path = postprocessor_path + self.prob_decoder_name = prob_decoder_name + self.prob_decoder_path = prob_decoder_path + + for util in ('preprocessor', 'postprocessor', 'prob_decoder'): + if getattr(self, '{}_path'.format(util)): + spec = importlib.util.\ + spec_from_file_location( + getattr(self, '{}_name'.format(util)), + getattr(self, '{}_path'.format(util))) + setattr(self, util, importlib.util.module_from_spec(spec)) + spec.loader.exec_module(getattr(self, util)) + + if kwargs: + for key, value in kwargs.items(): + setattr(self, key, value) + + def load(self, data_dir, **kwargs): + """Load the model in the desired framework + + Given a directory where model data (weights and graph + structure), should be able to restore the model locally to the point + where it can be evaluated. + + Args: + data_dir (:obj:`str`): full path to directory containing + weight and graph data + **kwargs: Arbitrary keyword arguments, useful for passing specific + settings to derived classes. + + """ + raise NotImplementedError + + def _predict(self, targets): + """Evaluate new examples and return class probablilites + + Given an iterable of examples or numpy array where the first + dimension is the number of example, return a n_examples x + n_classes array of class predictions + + Args: + targets: iterable of arrays suitable for input into graph + + Returns: + array of class probabilities + + """ + raise NotImplementedError + + def predict(self, raw_targets): + """Predict from raw data + + Takes an iterable of data in its raw format. Passes to the + preprocessor and then the child class _predict. + + Args: + raw_targets (:obj:`list` of :obj:`PIL.Image`): the images + to be processed + + Returns: + array of class probabilities + + """ + return self._predict(self.preprocess(raw_targets)) + + def preprocess(self, raw_targets): + """Preprocess raw input for evaluation by model + + Usually, input will need some preprocessing before submission + to a computation graph. For instance, the raw image may need + to converted to a numpy array of appropriate dimension + + Args: + raw_targets (:obj:`list` of :obj:`PIL.Image`): the images + to be processed + + Returns: + iterable of arrays of the correct shape for input into graph + + """ + try: + return getattr(self.preprocessor, + self.preprocessor_name)(raw_targets) + except AttributeError: + warnings.warn('Evaluating without preprocessor') + return raw_targets + + def postprocess(self, output_arr): + """Postprocess prediction results back into images + + Sometimes it's useful to display an intermediate computation + as image. This is model-dependent. + + Args: + output_arr (iterable of arrays): any array with the + same total number of entries an input array + + Returns: + iterable of arrays in original image shape + + """ + + try: + return getattr(self.postprocessor, + self.postprocessor_name)(output_arr) + except AttributeError: + warnings.warn('Evaluating without postprocessor') + return output_arr + + def decode_prob(self, output_arr): + """Label class probabilites with class names + + Args: + output_arr (array): class probabilities + + Returns: + result list of dict in the format [{'index': class_index, 'name': + class_name, 'prob': class_probability}, ...] + + """ + + try: + return getattr(self.prob_decoder, + self.prob_decoder_name)(output_arr, + top=self.top_probs) + except AttributeError: + warnings.warn('Evaluating without class decoder') + results = [] + for row in output_arr: + entries = [] + for i, prob in enumerate(row): + entries.append({'index': i, + 'name': str(i), + 'prob': prob}) + + entries = sorted(entries, + key=itemgetter('prob'), + reverse=True)[:self.top_probs] + + for entry in entries: + entry['prob'] = '{:.3f}'.format(entry['prob']) + results.append(entries) + return results + + +def generate_model(backend_ml, **kwargs): + """Create a new instance of ML backend + + Args: + backend_ml (:obj:`str`): name of the backend to use + **kwargs: Arbitrary keyword arguments + + Returns: + An instance of :class:`.ml_frameworks.model.Model` + + """ + module_name, _, class_name = \ + ML_LIBRARIES[backend_ml].rpartition('.') + + cls = getattr(import_module(module_name), class_name) + + kwargs = {k.partition('_')[-1]: + v for (k, v) in kwargs.items()} + return cls(**kwargs) diff --git a/picasso/ml_frameworks/tensorflow/__init__.py b/picasso/ml_frameworks/tensorflow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/picasso/ml_frameworks/tensorflow/model.py b/picasso/ml_frameworks/tensorflow/model.py new file mode 100644 index 0000000..7f11ffd --- /dev/null +++ b/picasso/ml_frameworks/tensorflow/model.py @@ -0,0 +1,56 @@ +import os +import glob +from datetime import datetime + +import tensorflow as tf + +from picasso.ml_frameworks.model import Model + + +class TFModel(Model): + """Implements model loading functions for tensorflow""" + + def load(self, data_dir='./'): + """Load graph and weight data + + Args: + data_dir (:obj:`str`): location of tensorflow checkpoint + data. We'll need the .meta file to reconstruct + the graph and the data (checkpoint) files to + fill in the weights of the model. The default + behavior is take the latest files, by OS timestamp. + + """ + + self.sess = tf.Session() + self.sess.as_default() + # find newest ckpt and meta files + try: + latest_ckpt_fn = max(glob.iglob(os.path.join(data_dir, '*.ckpt*')), + key=os.path.getctime) + self.latest_ckpt_time = str(datetime.fromtimestamp( + os.path.getmtime(latest_ckpt_fn) + )) + latest_ckpt = latest_ckpt_fn[:latest_ckpt_fn.rfind('.ckpt') + 5] + except ValueError: + raise FileNotFoundError('No checkpoint (.ckpt) files ' + 'available at {}'.format(data_dir)) + try: + latest_meta = max(glob.iglob(os.path.join(data_dir, '*.meta')), + key=os.path.getctime) + except ValueError: + raise FileNotFoundError('No graph (.meta) files ' + 'available at {}'.format(data_dir)) + + with self.sess.as_default() as sess: + self.saver = tf.train.import_meta_graph(latest_meta) + self.saver.restore(sess, latest_ckpt) + + self.tf_predict_var = \ + self.sess.graph.get_tensor_by_name(self.tf_predict_var) + self.tf_input_var = \ + self.sess.graph.get_tensor_by_name(self.tf_input_var) + + def _predict(self, input_array): + return self.sess.run(self.tf_predict_var, + {self.tf_input_var: input_array}) diff --git a/picasso/picasso.py b/picasso/picasso.py new file mode 100644 index 0000000..10d141a --- /dev/null +++ b/picasso/picasso.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +"""Flask server code for visualization + +This is the entry point for the application. All views and +logic for the webapp are laid out here. + +Examples: + To run using the Flask server (from the parent directory):: + + $ export FLASK_APP=picasso + $ flask run + + This will start the app with the default settings (using the + Keras backend and a convolutional MNIST digits classifier). + + To use a custom configuration, set an environment variable + to point to it like this:: + + $ export PICASSO_SETTINGS=/path/to/the/config.py + +Attributes: + APP_TITLE (:obj:`str`): Name of the application to display in the + title bar + VISUALIZATON_CLASSES(:obj:`tuple` of :class:`.BaseVisualization`): + Visualization classes available for rendering. + +""" +import os +import io +import time +import inspect +from operator import itemgetter +from tempfile import mkdtemp +from importlib import import_module +from types import ModuleType + +from PIL import Image +from flask import ( + g, + render_template, + request, + session, + send_from_directory +) + +from picasso import app +from picasso.ml_frameworks.model import generate_model +from picasso.visualizations import BaseVisualization +from picasso.visualizations import * + +APP_TITLE = 'Picasso Visualizer' + +# import visualizations classes dynamically +visualization_attr = vars( + import_module('picasso.visualizations')) +visualization_submodules = [visualization_attr[x] for x in visualization_attr + if isinstance(visualization_attr[x], ModuleType)] +VISUALIZATON_CLASSES = [] +for submodule in visualization_submodules: + members = vars(submodule) + classes = [members[x] for x in members if inspect.isclass(members[x]) and + issubclass(members[x], BaseVisualization) and + members[x] is not BaseVisualization] + VISUALIZATON_CLASSES += classes + +# Use a bogus secret key for debugging ease. No +# client information is stored, the secret key is only +# necessary for generating the session cookie. +if app.debug: + app.secret_key = '...' +else: + app.secret_key = os.urandom(24) + +# This pattern is used in other projects with Flask and +# tensorflow, but probably isn't the most stable or +# safest way. Would be much better to connect to a +# persistent tensorflow session running in another process or +# machine. +ml_backend = \ + generate_model( + **{k.lower(): v for (k, v) + in app.config.items() + if k.startswith('BACKEND')} + ) +ml_backend.load(app.config['DATA_DIR']) + + +def get_visualizations(): + """Get visualization classes in context + + Puts the available visualizations in the request context + and returns them. + + Returns: + :obj:`list` of instances of :class:`.BaseVisualization` or + derived class + + """ + if not hasattr(g, 'visualizations'): + g.visualizations = {} + for VisClass in VISUALIZATON_CLASSES: + vis = VisClass(get_ml_backend()) + g.visualizations[vis.__class__.__name__] = vis + + return g.visualizations + + +def get_ml_backend(): + """Get machine learning backend in context + + Puts the backend in the request context and returns it. + + Returns: + instance of :class:`.ml_frameworks.model.Model` or derived + class + """ + if not hasattr(g, 'ml_backend'): + g.ml_backend = ml_backend + return g.ml_backend + + +def get_app_state(): + """Get current status of application in context + + Returns: + :obj:`dict` of application status + + """ + if not hasattr(g, 'app_state'): + model = get_ml_backend() + g.app_state = { + 'app_title': APP_TITLE, + 'backend': type(model).__name__, + 'latest_ckpt_name': model.latest_ckpt_name, + 'latest_ckpt_time': model.latest_ckpt_time + } + return g.app_state + + +@app.route('/', methods=['GET', 'POST']) +def landing(): + """Landing page for the application + + If the request is `GET`, render the landing page. If + `POST`, then store the visualization in the session and + render the visualization settings page (if applicable) or + render file selection. + + """ + if request.method == 'POST': + session['vis_name'] = request.form.get('choice') + vis = get_visualizations()[session['vis_name']] + if hasattr(vis, 'settings'): + return visualization_settings() + return select_files() + + # otherwise, on GET request + visualizations = get_visualizations() + vis_desc = [{'name': vis, + 'description': visualizations[vis].description} + for vis in visualizations] + session.clear() + return render_template('select_visualization.html', + app_state=get_app_state(), + visualizations=sorted(vis_desc, + key=itemgetter('name')) + ) + + +@app.route('/visualization_settings', methods=['POST']) +def visualization_settings(): + """Visualization settings page + + Will only render if the visualization object has a `settings` + attribute. + + """ + if request.method == 'POST': + vis = get_visualizations()[session['vis_name']] + return render_template('settings.html', + app_state=get_app_state(), + current_vis=session['vis_name'], + settings=vis.settings) + + +@app.route('/select_files', methods=['GET', 'POST']) +def select_files(): + """File selection and final display of visualization + + If the request contains no files, then render the file + selection page. Otherwise render the visualization. + + Todo: + Logically, this route should be split into `select_files` + and `result`. + + """ + if 'file[]' in request.files: + vis = get_visualizations()[session['vis_name']] + inputs = [] + for file_obj in request.files.getlist('file[]'): + entry = {} + entry.update({'filename': file_obj.filename}) + # Why is this necessary? Unsure why Flask sometimes + # sends the files as bytestreams vs. strings. + try: + entry.update({'data': + Image.open( + io.BytesIO(file_obj.stream.getvalue()) + )}) + except AttributeError: + entry.update({'data': + Image.open( + io.BytesIO(file_obj.stream.read()) + )}) + inputs.append(entry) + + start_time = time.time() + session['img_output_dir'] = mkdtemp() + output = \ + vis.make_visualization(inputs, + output_dir=session['img_output_dir'], + settings=session['settings']) + duration = '{:.2f}'.format(time.time() - start_time, 2) + + for i, file_obj in enumerate(request.files.getlist('file[]')): + output[i].update({'filename': file_obj.filename}) + + temp_dir = mkdtemp() + session['img_input_dir'] = temp_dir + for entry in inputs: + path = os.path.join(temp_dir, entry['filename']) + entry['data'].save(path, 'PNG') + + kwargs = {} + if hasattr(vis, 'reference_link'): + kwargs.update({'reference_link': vis.reference_link}) + + return render_template('{}.html'.format(session['vis_name']), + inputs=inputs, + results=output, + current_vis=session['vis_name'], + settings=session['settings'], + app_state=get_app_state(), + duration=duration, + **kwargs) + + # otherwise, if no files in request + session['settings'] = request.form.to_dict() + if 'choice' in session['settings']: + session['settings'].pop('choice') + return render_template('select_files.html', + app_state=get_app_state(), + current_vis=session['vis_name'], + settings=session['settings']) + + +@app.route('/inputs/') +def download_inputs(filename): + """For serving input images""" + return send_from_directory(session['img_input_dir'], + filename) + + +@app.route('/outputs/') +def download_outputs(filename): + """For serving output images""" + return send_from_directory(session['img_output_dir'], + filename) + + +@app.errorhandler(500) +def internal_server_error(e): + return render_template('500.html', app_state=get_app_state()), 500 + + +@app.errorhandler(404) +def not_found_error(e): + return render_template('404.html', app_state=get_app_state()), 404 diff --git a/picasso/settings.py b/picasso/settings.py new file mode 100644 index 0000000..d469651 --- /dev/null +++ b/picasso/settings.py @@ -0,0 +1,44 @@ +import os + +base_dir = os.path.dirname(__file__) # only for default config + + +class Default: + """Default configuration settings + + The app will use these settings if none are specified. That is, + if no configuration file is specified by PICASSO_SETTINGS + or any individual setting is specified by environment variable. + These are, in effect, "settings of last resort." + + The paths will automatically be generated based on the location of + the source. + """ + + #: :obj:`str`: which backend to use + BACKEND_ML = 'keras' + + #: :obj:`str`: name of the preprocess function + BACKEND_PREPROCESSOR_NAME = 'preprocess' + + #: :obj:`str`: filepath of the preprocess function + BACKEND_PREPROCESSOR_PATH = os.path.join( + base_dir, 'examples', 'keras', 'util.py') + + #: :obj:`str`: name of the postprocess function + BACKEND_POSTPROCESSOR_NAME = 'postprocess' + + #: :obj:`str`: filepath of the postprocess function + BACKEND_POSTPROCESSOR_PATH = os.path.join( + base_dir, 'examples', 'keras', 'util.py') + + #: :obj:`str`: name of the probability decoder function + BACKEND_PROB_DECODER_NAME = 'prob_decode' + + #: :obj:`str`: filepath of the probability decoder function + BACKEND_PROB_DECODER_PATH = os.path.join( + base_dir, 'examples', 'keras', 'util.py') + + #: :obj:`str`: path to directory containing weights and graph + DATA_DIR = os.path.join( + base_dir, 'examples', 'keras', 'data-volume') diff --git a/picasso/static/favicon.ico b/picasso/static/favicon.ico new file mode 100644 index 0000000..be1a839 Binary files /dev/null and b/picasso/static/favicon.ico differ diff --git a/picasso/static/style.css b/picasso/static/style.css new file mode 100644 index 0000000..f6b5e89 --- /dev/null +++ b/picasso/static/style.css @@ -0,0 +1,3 @@ +.table tbody>tr>td.vert-align{ + vertical-align: middle; +} diff --git a/picasso/templates/404.html b/picasso/templates/404.html new file mode 100644 index 0000000..6957fc8 --- /dev/null +++ b/picasso/templates/404.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} +{% block body %} + + +

You tried to navigate to a page that doesn't exist. Why don't you start over and try again?

+ +{% endblock %} diff --git a/picasso/templates/500.html b/picasso/templates/500.html new file mode 100644 index 0000000..bad3552 --- /dev/null +++ b/picasso/templates/500.html @@ -0,0 +1,12 @@ +{% extends "layout.html" %} +{% block body %} + + +

Something went wrong. Perhaps you tried to upload a non-image file? In that case, start over and try again.

+ +

If you're running locally, you can restart Flask in debug mode and look at the stack trace.

+ +

Finally, if you've found a bug, please file an issue!

+{% endblock %} diff --git a/picasso/templates/ClassProbabilities.html b/picasso/templates/ClassProbabilities.html new file mode 100644 index 0000000..1cdf0c8 --- /dev/null +++ b/picasso/templates/ClassProbabilities.html @@ -0,0 +1,23 @@ +{% extends "result.html" %} +{% block vis %} + + + {% for result in results %} + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + + + {% for predict_prob in result.predict_probs %} + + {% endfor %} + + {% endfor %} + +
{{ result.filename }} {{ predict_prob.name }}
+ + {{ predict_prob.prob }}
+{% endblock %} diff --git a/picasso/templates/PartialOcclusion.html b/picasso/templates/PartialOcclusion.html new file mode 100644 index 0000000..eb77b1a --- /dev/null +++ b/picasso/templates/PartialOcclusion.html @@ -0,0 +1,29 @@ +{% extends "result.html" %} +{% block vis %} + + + {% for result in results %} + + + + {% for prob in result.predict_probs %} + + {% endfor %} + + + + + {% for filename in result.result_filenames %} + + {% endfor %} + + {% endfor %} + +
{{ result.filename }}Occlusion Grid{{ prob.name }}: {{ prob.prob }}
+ + + + + +
+{% endblock %} diff --git a/picasso/templates/SaliencyMaps.html b/picasso/templates/SaliencyMaps.html new file mode 100644 index 0000000..1b3e644 --- /dev/null +++ b/picasso/templates/SaliencyMaps.html @@ -0,0 +1,25 @@ +{% extends "result.html" %} +{% block vis %} + + + {% for result in results %} + + + {% for prob in result.predict_probs %} + + {% endfor %} + + + + {% for filename in result.gradient_image_names %} + + {% endfor %} + + {% endfor %} + +
{{ result.filename }}{{ prob.name }}: {{ prob.prob }}
+ + + +
+{% endblock %} diff --git a/picasso/templates/layout.html b/picasso/templates/layout.html new file mode 100644 index 0000000..cd30161 --- /dev/null +++ b/picasso/templates/layout.html @@ -0,0 +1,31 @@ + + + {{ app_state.app_title }} + + + + + + + + +
+

{{ app_state.app_title }} + by + Merantix + +

+
+

Current backend: {{ app_state.backend }}

+ {% if app_state.latest_ckpt_name is defined %} +

Current checkpoint: {{ app_state.latest_ckpt_name }}

+ {% endif %} + {% if app_state.latest_ckpt_time is defined %} +

Last updated: {{ app_state.latest_ckpt_time }}

+ {% endif %} + Start over +
+
+ {% block body %}{% endblock %} +
diff --git a/picasso/templates/result.html b/picasso/templates/result.html new file mode 100644 index 0000000..a2d77f2 --- /dev/null +++ b/picasso/templates/result.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} +{% block body %} +

Selected Visualization: {{ current_vis }}

+ {% if settings is defined %} + {% for setting in settings %} +

+ {{ setting }}: {{ settings[setting] }} +

+ {% endfor %} + {% endif %} + {% block vis %}{% endblock vis %} + {% if duration is defined %} +
+

Computing the visualization took {{ duration }} seconds. + {% endif %} + {% if reference_link is defined %} + Visualization Reference + {% endif %} +{% endblock %} diff --git a/picasso/templates/select_files.html b/picasso/templates/select_files.html new file mode 100644 index 0000000..e6665fd --- /dev/null +++ b/picasso/templates/select_files.html @@ -0,0 +1,22 @@ +{% extends "layout.html" %} +{% block body %} +

Selected Visualization: {{ current_vis }}

+ {% if settings is defined %} + {% for setting in settings %} +

+ {{ setting }}: {{ settings[setting] }} +

+ {% endfor %} + {% endif %} +
+
+ + +

Image types only, please. Will be resized to CNN input + shape.

+ +
+
+{% endblock %} diff --git a/picasso/templates/select_visualization.html b/picasso/templates/select_visualization.html new file mode 100644 index 0000000..1d9bee4 --- /dev/null +++ b/picasso/templates/select_visualization.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} +{% block body %} +
+
+ + +
+
+ +
+
+{% endblock %} diff --git a/picasso/templates/settings.html b/picasso/templates/settings.html new file mode 100644 index 0000000..d995af5 --- /dev/null +++ b/picasso/templates/settings.html @@ -0,0 +1,17 @@ +{% extends "layout.html" %} +{% block body %} +

Selected Visualization: {{ current_vis }}

+
+ {% for setting in settings %} +
+ + +
+ {% endfor %} + +
+{% endblock %} diff --git a/picasso/visualizations/__init__.py b/picasso/visualizations/__init__.py new file mode 100644 index 0000000..0146dd3 --- /dev/null +++ b/picasso/visualizations/__init__.py @@ -0,0 +1,42 @@ +"""Visualizations live here + +All default and user-defined visualizations are submodules of this +module. All classes defined in this directory (except BaseVisualization) +will be imported. + +""" +import os +__all__ = [x.rpartition('.')[0] for x in os.listdir(__path__[0]) + if not x.startswith('__') and x.endswith('py')] + + +class BaseVisualization: + """Template for visualizations + + Attributes: + description (:obj:`str`): short description of the visualization + model (instance of :class:`.ml_frameworks.model.Model` or derived class): + backend to use + settings (:obj:`dict`): a settings dictionary. Settings defined + here will be rendered in html for the user to select. See + derived classes for examples. + """ + def __init__(self, model): + self.model = model + + def make_visualization(self, inputs, output_dir, settings=None): + """Generate the visualization + + All visualizations must implement this method. + + Args: + inputs (iterable of :class:`PIL.Image`): images uploaded by the + user. Will have already been converted to :obj:`Image` + objects. + output_dir (:obj:`str`): a directory to store outputs (e.g. plots) + + Returns: + data needed to render the visualization. Since there is an + associated HTML template, the return type is arbitrary. + """ + raise NotImplementedError diff --git a/picasso/visualizations/class_probabilities.py b/picasso/visualizations/class_probabilities.py new file mode 100644 index 0000000..f87d5f3 --- /dev/null +++ b/picasso/visualizations/class_probabilities.py @@ -0,0 +1,26 @@ +from picasso.visualizations import BaseVisualization + + +class ClassProbabilities(BaseVisualization): + """Display top class probabilities for a given image + + This is the simplest kind of visualization -- it merely displays the top + class probabilities of the input image. + + """ + + description = 'Predict class probabilities from new examples' + + def make_visualization(self, inputs, + output_dir, settings=None): + pre_processed_arrays = self.model.preprocess([example['data'] + for example in inputs]) + predictions = self.model.sess.run(self.model.tf_predict_var, + feed_dict={self.model.tf_input_var: + pre_processed_arrays}) + filtered_predictions = self.model.decode_prob(predictions) + results = [] + for i, inp in enumerate(inputs): + results.append({'input_file_name': inp['filename'], + 'predict_probs': filtered_predictions[i]}) + return results diff --git a/picasso/visualizations/partial_occlusion.py b/picasso/visualizations/partial_occlusion.py new file mode 100644 index 0000000..239b4d6 --- /dev/null +++ b/picasso/visualizations/partial_occlusion.py @@ -0,0 +1,245 @@ +import os +import time + +import numpy as np +from PIL import Image + +import matplotlib +matplotlib.use('Agg') +from matplotlib import pyplot + +from picasso.visualizations import BaseVisualization + + +class PartialOcclusion(BaseVisualization): + """Partial occlusion visualization + + The partial occlusion class blocks out part of the image and checks + the classification. Regions where classification probability drops + significantly are likely very important to classification. + + The visualization can therefore be used to check if the model is + classifying on the image feature we expect. + + """ + settings = { + 'Window': ['0.50', '0.40', '0.30', '0.20', '0.10', '0.05'], + 'Strides': ['2', '5', '10', '20', '30'], + 'Occlusion': ['grey', 'black', 'white'] + } + + description = ('Partially occlude image to determine regions ' + 'important to classification') + reference_link = 'https://arxiv.org/abs/1311.2901' + + def __init__(self, model): + super(PartialOcclusion, self).__init__(model) + self.predict_tensor = self.get_predict_tensor() + + self.window = 0.10 + self.num_windows = 20 + self.grid_percent = 0.01 + self.occlusion_method = 'white' + self.occlusion_value = 255 + self.initial_resize = (244, 244) + + def make_visualization(self, inputs, output_dir, settings=None): + if settings: + self.update_settings(settings) + if self.occlusion_method == 'black': + self.occlusion_value = 0 + elif self.occlusion_method == 'grey': + self.occlusion_value = 128 + + # get class predictions as in ClassProbabilities + pre_processed_arrays = self.model.preprocess([example['data'] + for example in inputs]) + class_predictions = \ + self.model.sess.run(self.model.tf_predict_var, + feed_dict={self.model.tf_input_var: + pre_processed_arrays}) + decoded_predictions = self.model.decode_prob(class_predictions) + + results = [] + for i, example in enumerate(inputs): + im = example['data'] + im_format = im.format + if self.initial_resize: + im = im.resize(self.initial_resize, Image.ANTIALIAS) + + occ_im = self.occluded_images(im) + predictions = self.model.sess.run( + self.predict_tensor, + feed_dict={self.model.tf_input_var: + self.model.preprocess(occ_im['occluded_images'])}) + + example_im = self.make_example_image(im, + occ_im['centers_horizontal'], + occ_im['centers_vertical'], + occ_im['win_width'], + occ_im['win_length'], + occ_im['pad_vertical'], + occ_im['pad_horizontal']) + example_filename = '{ts}{fn}'.format(ts=str(time.time()), + fn=example['filename']) + example_im.save( + os.path.join(output_dir, example_filename), + format=im_format) + + filenames = \ + self.make_heatmaps(predictions, + output_dir, + example['filename'], + decoded_predictions=decoded_predictions[i]) + results.append({'input_filename': example['filename'], + 'result_filenames': filenames, + 'predict_probs': decoded_predictions[i], + 'example_filename': example_filename}) + return results + + def get_predict_tensor(self): + # Assume that predict is the softmax + # tensor in the computation graph + return self.model.sess.graph. \ + get_tensor_by_name(self.model.tf_predict_var.name) + + def update_settings(self, settings): + def error_string(setting, setting_val): + return ('{val} is not an acceptable value for ' + 'parameter {param} for visualization' + '{vis}.').format(val=setting_val, + param=setting, + vis=self.__class__.__name__) + + if 'Window' in settings: + if settings['Window'] in self.settings['Window']: + self.window = float(settings['Window']) + else: + raise ValueError(error_string(settings['Window'], 'Window')) + + if 'Strides' in settings: + if settings['Strides'] in self.settings['Strides']: + self.num_windows = int(settings['Strides']) + else: + raise ValueError(error_string(settings['Strides'], 'Strides')) + + if 'Occlusion' in settings: + if settings['Occlusion'] in self.settings['Occlusion']: + self.occlusion_method = settings['Occlusion'] + else: + raise ValueError(error_string(settings['Occlusion'], + 'Occlusion')) + + def make_heatmaps(self, predictions, + output_dir, filename, + decoded_predictions=None): + if decoded_predictions: + relevant_class_indices = [pred['index'] + for pred in decoded_predictions] + predictions = predictions[:, relevant_class_indices] + stacked_heatmaps = predictions.reshape(self.num_windows, + self.num_windows, + predictions.shape[-1]) + filenames = [] + for i in range(predictions.shape[-1]): + grid = stacked_heatmaps[:, :, i] + pyplot.axis('off') + if i == 0: + im = pyplot.imshow(grid, vmin=0, vmax=1) + pyplot.axis('off') + im.axes.get_xaxis().set_visible(False) + im.axes.get_yaxis().set_visible(False) + else: + im.set_data(grid) + hm_filename = '{ts}{label}_{fn}'.format(ts=str(time.time()), + label=str(i), + fn=filename) + pyplot.savefig(os.path.join(output_dir, hm_filename), + format='PNG', bbox_inches='tight', pad_inches=0) + filenames.append(hm_filename) + return filenames + + def occluded_images(self, im): + width = im.size[0] + length = im.size[1] + win_width = round(self.window * width) + win_length = round(self.window * length) + pad_horizontal = win_width // 2 + pad_vertical = win_length // 2 + centers_horizontal, centers_vertical = \ + self.get_centers(width, length, + win_width, win_length, + pad_horizontal, pad_vertical, + self.num_windows + ) + upper_left_corners = np.array( + [(w - pad_vertical, v - pad_horizontal) + for w in centers_vertical + for v in centers_horizontal] + ) + + images = [] + for corner in upper_left_corners: + arr = np.array(im) + self.add_occlusion_to_arr(arr, corner, + win_width, win_length, + occ_val=self.occlusion_value) + images.append( + Image.fromarray(arr) + ) + + return {'occluded_images': images, + 'centers_horizontal': centers_horizontal, + 'centers_vertical': centers_vertical, + 'win_width': win_width, + 'win_length': win_length, + 'pad_horizontal': pad_horizontal, + 'pad_vertical': pad_vertical} + + def make_example_image(self, im, + centers_horizontal, centers_vertical, + win_width, win_length, pad_vertical, + pad_horizontal, output_size=(244, 244)): + arr = np.array(im) + # add an example occlusion + self.add_occlusion_to_arr(arr, + (centers_vertical[1] - pad_vertical, + centers_horizontal[1] - pad_horizontal), + win_width, win_length, occ_val=100) + # add grid + g_pad_vertical = round(self.grid_percent * im.size[1]) or 1 + g_pad_horizontal = round(self.grid_percent * im.size[0]) or 1 + w_grid = 2 * g_pad_horizontal + l_grid = 2 * g_pad_vertical + upper_left_corners = np.array( + [(w - g_pad_vertical, v - g_pad_horizontal) + for w in centers_vertical + for v in centers_horizontal] + ) + for corner in upper_left_corners: + self.add_occlusion_to_arr(arr, corner, + w_grid, l_grid) + return Image.fromarray(arr) + + @staticmethod + def get_centers(width, length, + win_width, win_length, + pad_horizontal, pad_vertical, + num_windows): + centers_horizontal = np.linspace(pad_horizontal, + width - pad_horizontal, + num_windows).astype('int') + centers_vertical = np.linspace(pad_vertical, + length - pad_vertical, + num_windows).astype('int') + return centers_horizontal, centers_vertical + + @staticmethod + def add_occlusion_to_arr(arr, upper_left_corner, + width_horizontal, + width_vertical, + occ_val=0): + arr[upper_left_corner[0]: + upper_left_corner[0] + width_vertical, + upper_left_corner[1]: + upper_left_corner[1] + width_horizontal] = occ_val diff --git a/picasso/visualizations/saliency_maps.py b/picasso/visualizations/saliency_maps.py new file mode 100644 index 0000000..060df75 --- /dev/null +++ b/picasso/visualizations/saliency_maps.py @@ -0,0 +1,113 @@ +import os +import time + +import numpy as np +import tensorflow as tf + +import matplotlib +matplotlib.use('Agg') +from matplotlib import pyplot + +from picasso.visualizations import BaseVisualization + + +class SaliencyMaps(BaseVisualization): + """Derivative of classification with respect to input pixels + + Saliency maps are a way of showing which inputs matter most to + classification. The derivative of a class probability with + respect to each input pixel are found with backpropagation. + High values for the derivative indicate pixels important to + classification (as changing them would change the classification). + + """ + description = ('See maximal derivates against class with respect ' + 'to input') + reference_link = 'https://arxiv.org/pdf/1312.6034' + + def __init__(self, model, logit_tensor_name=None): + super(SaliencyMaps, self).__init__(model) + if logit_tensor_name: + self.logit_tensor = self.model.sess.graph \ + .get_tensor_by_name(logit_tensor_name) + else: + self.logit_tensor = self.get_logit_tensor() + + def get_gradient_wrt_class(self, class_index): + gradient_name = 'bv_{class_index}_gradient' \ + .format(class_index=class_index) + try: + return self.model.sess.graph. \ + get_tensor_by_name('{}:0'.format(gradient_name)) + except KeyError: + class_logit = tf.slice(self.logit_tensor, + [0, class_index], + [1, 1]) + return tf.gradients(class_logit, + self.model.tf_input_var, + name=gradient_name)[0] + + def make_visualization(self, inputs, output_dir, settings=None): + + pre_processed_arrays = self.model.preprocess([example['data'] + for example in inputs]) + + # get predictions + predictions = self.model.sess.run(self.model.tf_predict_var, + feed_dict={self.model.tf_input_var: + pre_processed_arrays}) + decoded_predictions = self.model.decode_prob(predictions) + + results = [] + for i, inp in enumerate(inputs): + class_gradients = [] + output_images = [] + relevant_class_indices = [pred['index'] + for pred in decoded_predictions[i]] + gradients_wrt_class = [self.get_gradient_wrt_class(index) for index + in relevant_class_indices] + for gradient_wrt_class in gradients_wrt_class: + class_gradients.append([self.model.sess.run( + gradient_wrt_class, + feed_dict={self.model.tf_input_var: [arr]}) + for arr in pre_processed_arrays]) + output_fns = [] + output_arrays = np.array([gradient[i] for + gradient in class_gradients]) + # if images are color, take the maximum channel + if output_arrays.shape[-1] == 3: + output_arrays = output_arrays.max(-1) + + output_images = self.model.postprocess(np.abs(output_arrays)) + for j, image in enumerate(output_images): + output_fn = '{fn}-{j}-{ts}.png'.format(ts=str(time.time()), + j=j, + fn=inp['filename']) + + if i == 0 and j == 0: + im = pyplot.imshow(image, + cmap='Greys_r') + pyplot.axis('off') + im.axes.get_xaxis().set_visible(False) + im.axes.get_yaxis().set_visible(False) + else: + im.set_data(image) + + pyplot.savefig(os.path.join(output_dir, output_fn), + bbox_inches='tight', pad_inches=0) + output_fns.append(output_fn) + + results.append({'input_file_name': inp['filename'], + 'predict_probs': decoded_predictions[i], + 'gradient_image_names': output_fns}) + return results + + def get_logit_tensor(self): + # Assume that the logits are the tensor input to the last softmax + # operation in the computation graph + sm = [node for node in self.model.sess.graph_def.node + if node.name == + self.model.tf_predict_var.name.split(':')[0]][-1] + logit_op_name = sm.input[0] + return self.model.sess.graph. \ + get_tensor_by_name('{}:0'.format(logit_op_name)) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..5bb36ef --- /dev/null +++ b/setup.cfg @@ -0,0 +1,21 @@ +[bumpversion] +current_version = v0.1.1 +commit = True +tag = True + +[bumpversion:file:setup.py] +search = version='{current_version}' +replace = version='{new_version}' + +[bumpversion:file:picasso/__init__.py] +search = __version__ = '{current_version}' +replace = __version__ = '{new_version}' + +[bdist_wheel] +universal = 1 + +[flake8] +exclude = docs + +[aliases] +test = pytest diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5abb552 --- /dev/null +++ b/setup.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from setuptools import setup, find_packages + +with open('README.rst') as readme_file: + readme = readme_file.read() + +with open('HISTORY.rst') as history_file: + history = history_file.read() + +requirements = [ + 'click>=6.7', + 'cycler>=0.10.0', + 'Flask>=0.12', + 'h5py>=2.6.0', + 'itsdangerous>=0.24', + 'Jinja2>=2.9.5', + 'Keras>=1.2.2', + 'MarkupSafe>=0.23', + 'matplotlib>=2.0.0', + 'numpy>=1.12.0', + 'olefile>=0.44', + 'packaging>=16.8', + 'Pillow>=4.0.0', + 'protobuf>=3.2.0', + 'pyparsing>=2.1.10', + 'python-dateutil>=2.6.0', + 'pytz>=2016.10', + 'PyYAML>=3.12', + 'requests>=2.13.0', + 'scipy>=0.18.1', + 'six>=1.10.0', + 'tensorflow>=1.0.0', + 'Werkzeug>=0.11.15', +] + +test_requirements = [ + 'pytest', + 'pytest-flask', +] + +docs_require = [ + 'Sphinx', + 'sphinxcontrib-napoleon', + 'sphinx-rtd-theme' +] + +setup( + name='picasso_viz', + version='v0.1.1', + description="A CNN model visualizer", + long_description=readme + '\n\n' + history, + author="Ryan Henderson", + author_email='ryan@merantix.com', + url='https://github.com/merantix/picasso', + packages=find_packages(), + entry_points={ + 'console_scripts': [ + 'picasso=picasso.commands:main' + ], + }, + include_package_data=True, + package_data={'picasso': ['examples/keras/*', + 'examples/tensorflow/*', + 'examples/keras-vgg16/*', + 'examples/keras/data-volume/*', + 'examples/tensorflow/data-volume/*', + 'examples/keras-vgg16/data-volume/*', + 'templates/*', + 'static/*']}, + install_requires=requirements, + license="Eclipse Public License 1.0 (EPL-1.0)", + zip_safe=False, + keywords='picasso', + classifiers=[ + 'Development Status :: 2 - Pre-Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Eclipse Public License 1.0 (EPL-1.0)', + 'Natural Language :: English', + 'Programming Language :: Python :: 3.5', + ], + test_suite='tests', + tests_require=test_requirements, + extras_require={ + 'test': test_requirements, + 'docs': docs_require + }, + setup_requires=['pytest_runner'] +) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..777ac6f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +from PIL import Image +import numpy as np +import pytest + +from picasso import app as _app + + +@pytest.fixture +def app(): + return _app + + +@pytest.fixture(scope='session') +def random_image_files(tmpdir_factory): + fn = tmpdir_factory.mktemp('images') + for i in range(4): + imarray = np.random.rand(10**i, 10**i, 3) * 255 + img = Image.fromarray(imarray.astype('uint8')).convert('RGBA') + img.save(str(fn.join('{}.png'.format(i))), 'PNG') + return fn + + +@pytest.fixture +def example_prob_array(): + return np.random.random((3, 10)) + + +@pytest.fixture +def base_model(): + from picasso.ml_frameworks.model import Model + return Model() diff --git a/tests/test_picasso.py b/tests/test_picasso.py new file mode 100644 index 0000000..bda8738 --- /dev/null +++ b/tests/test_picasso.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +test_picasso +---------------------------------- + +Tests for `picasso` module. +""" +import os + +from flask import url_for +import pytest +from werkzeug.test import EnvironBuilder + + +class TestWebApp: + from picasso.picasso import VISUALIZATON_CLASSES + + def test_landing_page_get(self, client): + assert client.get(url_for('landing')).status_code == 200 + + @pytest.mark.parametrize("vis", VISUALIZATON_CLASSES) + def test_landing_page_post(self, client, vis): + rv = client.post(url_for('landing'), + data=dict(choice=vis.__name__)) + assert rv.status_code == 200 + + @pytest.mark.parametrize("vis", VISUALIZATON_CLASSES) + def test_settings_page(self, client, vis): + if hasattr(vis, 'settings'): + with client.session_transaction() as sess: + sess['vis_name'] = vis.__name__ + rv = client.post(url_for('visualization_settings')) + assert rv.status_code == 200 + + @pytest.mark.parametrize("vis", VISUALIZATON_CLASSES) + def test_file_selection_get(self, client, vis): + with client.session_transaction() as sess: + sess['vis_name'] = vis.__name__ + rv = client.get(url_for('select_files')) + assert rv.status_code == 200 + + @pytest.mark.parametrize("vis", VISUALIZATON_CLASSES) + def test_file_selection_post(self, client, vis, random_image_files): + with client.session_transaction() as sess: + sess['vis_name'] = vis.__name__ + # load some settings into the session if the visualization calls + # for it + if hasattr(vis, 'settings'): + sess['settings'] = {key: vis.settings[key][0] + for key in vis.settings} + else: + sess['settings'] = {} + + # random images + builder = EnvironBuilder(path=url_for('select_files'), method='POST') + for path in random_image_files.listdir(): + path = str(path) + builder.files.add_file('file[]', path, + filename=os.path.split(str(path))[-1]) + rv = client.post(url_for('select_files'), data=builder.files) + assert rv.status_code == 200 + + +class TestBaseModel: + + def test_decode_prob(self, base_model, example_prob_array): + results = base_model.decode_prob(example_prob_array) + for i, result in enumerate(results): + max_val = max(example_prob_array[i]) + assert result[0]['prob'] == '{:.3f}'.format(max_val) + assert result[0]['index'] == example_prob_array[i].argmax() + assert result[0]['name'] == str(result[0]['index']) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..1b51d2d --- /dev/null +++ b/tox.ini @@ -0,0 +1,22 @@ +[tox] +envlist = py26, py27, py33, py34, py35, flake8 + +[testenv:flake8] +basepython=python +deps=flake8 +commands=flake8 picasso + +[testenv] +setenv = + PYTHONPATH = {toxinidir}:{toxinidir}/picasso +deps = + -r{toxinidir}/requirements_dev.txt +commands = + pip install -U pip + py.test --basetemp={envtmpdir} + + +; If you want to make tox run the tests with the same versions, create a +; requirements.txt with the pinned versions and uncomment the following lines: +; deps = +; -r{toxinidir}/requirements.txt diff --git a/travis_pypi_setup.py b/travis_pypi_setup.py new file mode 100644 index 0000000..fdf3980 --- /dev/null +++ b/travis_pypi_setup.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Update encrypted deploy password in Travis config file +""" + + +from __future__ import print_function +import base64 +import json +import os +from getpass import getpass +import yaml +from cryptography.hazmat.primitives.serialization import load_pem_public_key +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 + + +try: + from urllib import urlopen +except: + from urllib.request import urlopen + + +GITHUB_REPO = 'merantix/picasso' +TRAVIS_CONFIG_FILE = os.path.join( + os.path.dirname(os.path.abspath(__file__)), '.travis.yml') + + +def load_key(pubkey): + """Load public RSA key, with work-around for keys using + incorrect header/footer format. + + Read more about RSA encryption with cryptography: + https://cryptography.io/latest/hazmat/primitives/asymmetric/rsa/ + """ + try: + return load_pem_public_key(pubkey.encode(), default_backend()) + except ValueError: + # workaround for https://github.com/travis-ci/travis-api/issues/196 + pubkey = pubkey.replace('BEGIN RSA', 'BEGIN').replace('END RSA', 'END') + return load_pem_public_key(pubkey.encode(), default_backend()) + + +def encrypt(pubkey, password): + """Encrypt password using given RSA public key and encode it with base64. + + The encrypted password can only be decrypted by someone with the + private key (in this case, only Travis). + """ + key = load_key(pubkey) + encrypted_password = key.encrypt(password, PKCS1v15()) + return base64.b64encode(encrypted_password) + + +def fetch_public_key(repo): + """Download RSA public key Travis will use for this repo. + + Travis API docs: http://docs.travis-ci.com/api/#repository-keys + """ + keyurl = 'https://api.travis-ci.org/repos/{0}/key'.format(repo) + data = json.loads(urlopen(keyurl).read().decode()) + if 'key' not in data: + errmsg = "Could not find public key for repo: {}.\n".format(repo) + errmsg += "Have you already added your GitHub repo to Travis?" + raise ValueError(errmsg) + return data['key'] + + +def prepend_line(filepath, line): + """Rewrite a file adding a line to its beginning. + """ + with open(filepath) as f: + lines = f.readlines() + + lines.insert(0, line) + + with open(filepath, 'w') as f: + f.writelines(lines) + + +def load_yaml_config(filepath): + with open(filepath) as f: + return yaml.load(f) + + +def save_yaml_config(filepath, config): + with open(filepath, 'w') as f: + yaml.dump(config, f, default_flow_style=False) + + +def update_travis_deploy_password(encrypted_password): + """Update the deploy section of the .travis.yml file + to use the given encrypted password. + """ + config = load_yaml_config(TRAVIS_CONFIG_FILE) + + config['deploy']['password'] = dict(secure=encrypted_password) + + save_yaml_config(TRAVIS_CONFIG_FILE, config) + + line = ('# This file was autogenerated and will overwrite' + ' each time you run travis_pypi_setup.py\n') + prepend_line(TRAVIS_CONFIG_FILE, line) + + +def main(args): + public_key = fetch_public_key(args.repo) + password = args.password or getpass('PyPI password: ') + update_travis_deploy_password(encrypt(public_key, password.encode())) + print("Wrote encrypted password to .travis.yml -- you're ready to deploy") + + +if '__main__' == __name__: + import argparse + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--repo', default=GITHUB_REPO, + help='GitHub repo (default: %s)' % GITHUB_REPO) + parser.add_argument('--password', + help='PyPI password (will prompt if not provided)') + + args = parser.parse_args() + main(args)