diff --git a/saber/webanno/.gitignore b/saber/webanno/.gitignore new file mode 100644 index 0000000..ef85225 --- /dev/null +++ b/saber/webanno/.gitignore @@ -0,0 +1,146 @@ +.venv + +# Created by https://www.gitignore.io/api/code,python,macos +# Edit at https://www.gitignore.io/?templates=code,python,macos + +### Code ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# End of https://www.gitignore.io/api/code,python,macos diff --git a/saber/webanno/README.md b/saber/webanno/README.md new file mode 100644 index 0000000..06bc318 --- /dev/null +++ b/saber/webanno/README.md @@ -0,0 +1,55 @@ +# webanno + +A human-facing web annotation tool for different flavors of data. + + + +## Overview + +The `webanno` tool runs a Python process that exposes a web server with a single user-facing web-page which holds developer-defined data to annotate (perhaps it's text, perhaps it's a webform, perhaps it's imagery...). Once the data are annotated and submitted, the web server shuts down gracefully (exitcode 0), which means you can use the output data in a SABER workflow. + +### Why not a conventional web app? + +A conventional web application process does not stop once the data are annotated, which means you cannot run it "in-line" with the rest of a SABER workflow. + +## How do I pick the data to annotate? + +`webanno` uses a plugin architecture system: A plugin defines both the HTML of the page as well as what to do with the data when annotation is over. + +There are many already-existing annotation plugins: + +### `ButtonPressPlugin` + +This plugin is the simplest (and a good example to learn from!), and just monitors when a button is pressed. + +### `FormInputPlugin` + +This plugin lets you create a custom form in HTML for a user to fill out. + +### `CentroidsPlugin` + +This plugin lets a user place points in a 2D image, and returns a JSON file of the coordinates of those clicks. + +### `BossVolumePlugin` + +This plugin lets a user place points in a 3D volume downloaded from BossDB. + + +## Installation + +To get started, you need only pip-install this package and its dependencies: + +```shell +pip3 install -r requirements.txt +pip3 install -e . +``` + +## Usage + +Use the `webanno` tool from the command-line: + +```shell +webanno examples/example_centroids_image.json +``` + +Results will be stored in `results.json` in PWD. If you would like to store them elsewhere, set `json_file_output` in any config file (all plugins accept this config value). diff --git a/saber/webanno/examples/example_basic.json b/saber/webanno/examples/example_basic.json new file mode 100644 index 0000000..6388039 --- /dev/null +++ b/saber/webanno/examples/example_basic.json @@ -0,0 +1,4 @@ +{ + "plugin": "webanno.plugins.ButtonPressPlugin", + "config": {} +} diff --git a/saber/webanno/examples/example_boss_centroids.json b/saber/webanno/examples/example_boss_centroids.json new file mode 100644 index 0000000..1f51f2f --- /dev/null +++ b/saber/webanno/examples/example_boss_centroids.json @@ -0,0 +1,22 @@ +{ + "plugin": "webanno.plugins.BossVolumePlugin", + "config": { + "boss_uri": "bossdb://https://api.bossdb.io/wilson2019/P7/em", + "start": [ + 32820, + 26731, + 32 + ], + "stop": [ + 33616, + 27500, + 46 + ], + "res": 0, + "dot_color": [ + 255, + 0, + 255 + ] + } +} diff --git a/saber/webanno/examples/example_centroids_image.json b/saber/webanno/examples/example_centroids_image.json new file mode 100644 index 0000000..3b003d2 --- /dev/null +++ b/saber/webanno/examples/example_centroids_image.json @@ -0,0 +1,8 @@ +{ + "plugin": "webanno.plugins.CentroidsPlugin", + "config": { + "max_count": 5, + "prompt": "Click on the eyes.", + "image_url": "http://www.fillmurray.com/600/600/" + } +} diff --git a/saber/webanno/examples/example_form_input.json b/saber/webanno/examples/example_form_input.json new file mode 100644 index 0000000..e1187eb --- /dev/null +++ b/saber/webanno/examples/example_form_input.json @@ -0,0 +1,20 @@ +{ + "plugin": "webanno.plugins.FormInputPlugin", + "config": { + "fields": [ + { + "name": "name", + "type": "text" + }, + { + "name": "age", + "type": "number" + }, + { + "name": "todays_date", + "type": "time", + "label": "When did you wake up today?" + } + ] + } +} diff --git a/saber/webanno/requirements.txt b/saber/webanno/requirements.txt new file mode 100644 index 0000000..3ec9767 --- /dev/null +++ b/saber/webanno/requirements.txt @@ -0,0 +1,5 @@ +flask +flask_cors +pillow +intern +numpy diff --git a/saber/webanno/scripts/webanno b/saber/webanno/scripts/webanno new file mode 100644 index 0000000..e7ae6ce --- /dev/null +++ b/saber/webanno/scripts/webanno @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 + +from webanno import run + +run() diff --git a/saber/webanno/setup.py b/saber/webanno/setup.py new file mode 100644 index 0000000..2f88d7b --- /dev/null +++ b/saber/webanno/setup.py @@ -0,0 +1,30 @@ +import os +from distutils.core import setup + +""" +git tag {VERSION} +git push --tags +python setup.py sdist upload -r pypi +""" + +VERSION = "0.1.0" + +setup( + name="webanno", + version=VERSION, + author="Jordan Matelsky", + author_email="j6k4m8@gmail.com", + description=("Web annotation plugin platform"), + license="Apache 2.0", + keywords=[ + ], + url="https://github.com/aplbrain/webanno/tarball/" + VERSION, + packages=['webanno'], + scripts=[ + 'scripts/webanno' + ], + classifiers=[], + install_requires=[ + 'flask', 'flask_cors' + ], +) diff --git a/saber/webanno/test.png b/saber/webanno/test.png new file mode 100644 index 0000000..1d23c6a Binary files /dev/null and b/saber/webanno/test.png differ diff --git a/saber/webanno/webanno/__init__.py b/saber/webanno/webanno/__init__.py new file mode 100644 index 0000000..189212a --- /dev/null +++ b/saber/webanno/webanno/__init__.py @@ -0,0 +1,41 @@ +import sys +import importlib +import json +from flask import Flask, request, render_template +from flask_cors import CORS + + +from webanno.plugins import Plugin + + +def shutdown_server(): + func = request.environ.get("werkzeug.server.shutdown") + if func is None: + raise RuntimeError("Not running with the Werkzeug Server") + func() + + +def attach_plugin(plugin: Plugin, app: Flask): + CORS(app) + + def _prompt(): + return plugin.prompt().replace("[[SUBMIT_URL]]", "http://localhost:5000/submit") + + def _submit(): + plugin.collect(request.json, request) + shutdown_server() + return "ok" + + for path, route_fn in plugin.routes().items(): + app.add_url_rule("/plugin/" + path.lstrip("/"), path, route_fn) + + app.add_url_rule("/", "prompt", _prompt) + app.add_url_rule("/submit", "submit", _submit, methods=["POST"]) + + +def run(): + APP = Flask(__name__) + config = json.load(open(sys.argv[1], "r")) + plugin = importlib.import_module(config["plugin"]) + attach_plugin(plugin._Plugin(config["config"]), APP) + APP.run(debug=True) diff --git a/saber/webanno/webanno/plugins/BossVolumePlugin.py b/saber/webanno/webanno/plugins/BossVolumePlugin.py new file mode 100644 index 0000000..b8302cb --- /dev/null +++ b/saber/webanno/webanno/plugins/BossVolumePlugin.py @@ -0,0 +1,76 @@ +from typing import Tuple +import json +import io +import os.path + +import numpy as np +from flask import send_file +from PIL import Image +from intern.remote.boss import BossRemote +from .plugin import Plugin + + +def _get_boss_remote_and_channel( + uri: str, token: str = "public" +) -> Tuple[BossRemote, "Channel"]: + _, protocol, url = uri.split("://") + host, col, exp, chan = url.split("/") + boss = BossRemote({"protocol": protocol, "host": host, "token": token}) + return (boss, boss.get_channel(chan, col, exp)) + + +my_path = os.path.abspath(os.path.dirname(__file__)) +path = os.path.join(my_path, "templates/BossVolumePlugin.html") +with open(path) as f: + HTML_PAGE = f.read() + + +class CentroidsPlugin(Plugin): + def __init__(self, config: dict): + self.json_file_output = config.get("json_file_output", "results.json") + self.config = config + self.text_prompt = config.get("prompt", "") + self.max_count = config.get("max_count", "undefined") + self.dot_color = config.get("dot_color", [255, 255, 255]) + self.boss_uri = config["boss_uri"] + self.boss, self.channel = _get_boss_remote_and_channel(self.boss_uri) + self.start = config["start"] + self.stop = config["stop"] + self.xs = [self.start[0], self.stop[0]] + self.ys = [self.start[1], self.stop[1]] + self.zs = [self.start[2], self.stop[2]] + self.resolution = config.get("resolution", 0) + + def prompt(self) -> str: + return ( + HTML_PAGE.replace("[[PROMPT]]", self.text_prompt) + .replace("[[IMAGE_URL]]", self.boss_uri) + .replace("[[MAX_COUNT]]", str(self.max_count)) + .replace("[[DOT_COLOR]]", "[{}, {}, {}]".format(*self.dot_color)) + .replace( + "[[IMG_SIZE]]", + f"[{self.xs[1] - self.xs[0]}, {self.ys[1] - self.ys[0]}, {self.zs[1] - self.zs[0]}]", + ) + ) + + def collect(self, data: dict, response): + # Response is a Flask response + with open(self.json_file_output, "w") as fh: + json.dump(data, fh) + return True + + def routes(self): + return {"/volume": self._route_get_volume_as_filmstrip} + + def _route_get_volume_as_filmstrip(self): + data = self.boss.get_cutout( + self.channel, self.resolution, self.xs, self.ys, self.zs + ) + img = Image.fromarray(np.concatenate([z for z in data], axis=0)) + imgio = io.BytesIO() + img.save(imgio, format="jpeg") + imgio.seek(0) + return send_file(imgio, mimetype="image/jpeg") + + +_Plugin = CentroidsPlugin diff --git a/saber/webanno/webanno/plugins/ButtonPressPlugin.py b/saber/webanno/webanno/plugins/ButtonPressPlugin.py new file mode 100644 index 0000000..0f6cd60 --- /dev/null +++ b/saber/webanno/webanno/plugins/ButtonPressPlugin.py @@ -0,0 +1,39 @@ +import json +from .plugin import Plugin + + +class ButtonPressPlugin(Plugin): + def __init__(self, config: dict): + self.json_file_output = config.get("json_file_output", "results.json") + + def prompt(self) -> str: + return """ + + + + + + + """ + + def collect(self, data: dict): + with open(self.json_file_output, "w") as fh: + json.dump(data, fh) + return True + + +_Plugin = ButtonPressPlugin diff --git a/saber/webanno/webanno/plugins/CentroidsPlugin.py b/saber/webanno/webanno/plugins/CentroidsPlugin.py new file mode 100644 index 0000000..a48ace3 --- /dev/null +++ b/saber/webanno/webanno/plugins/CentroidsPlugin.py @@ -0,0 +1,88 @@ +import json +from .plugin import Plugin + +HTML_PAGE = """ + + + + + +

[[PROMPT]]

+
+ + + +""" + + +class CentroidsPlugin(Plugin): + def __init__(self, config: dict): + self.json_file_output = config.get("json_file_output", "results.json") + self.config = config + self.text_prompt = config.get("prompt", "") + self.max_count = config.get("max_count", "undefined") + self.image_url = config["image_url"] + + def prompt(self) -> str: + return ( + HTML_PAGE.replace("[[PROMPT]]", self.text_prompt) + .replace("[[IMAGE_URL]]", self.image_url) + .replace("[[MAX_COUNT]]", str(self.max_count)) + ) + + def collect(self, data: dict, response): + # Response is a Flask response + with open(self.json_file_output, "w") as fh: + json.dump(data, fh) + return True + + +_Plugin = CentroidsPlugin diff --git a/saber/webanno/webanno/plugins/FormInputPlugin.py b/saber/webanno/webanno/plugins/FormInputPlugin.py new file mode 100644 index 0000000..a242f5f --- /dev/null +++ b/saber/webanno/webanno/plugins/FormInputPlugin.py @@ -0,0 +1,93 @@ +import json +from .plugin import Plugin + + +HTML_PAGE = """ + + + + +
+
+
+
+
+
+
+ [[FORM_HTML]] +
+
+ +
+
+
+
+
+
+
+ + +""" + + +class FormInputPlugin(Plugin): + def __init__(self, config: dict): + self.json_file_output = config.get("json_file_output", "results.json") + self.config = config + + def _fields_html(self) -> str: + html = "" + for field in self.config["fields"]: + html += f""" +
+ + +
+ """ + return html + + def _fields_as_js(self) -> str: + js = "" + for field in self.config["fields"]: + js += f""" + "{field['name']}": document.getElementById('{field['name']}').value, + """ + return js + + def prompt(self) -> str: + return HTML_PAGE.replace("[[FORM_HTML]]", self._fields_html()).replace( + "[[FORM_AS_JS]]", self._fields_as_js() + ) + + def collect(self, data: dict, response): + # Response is a Flask response + with open(self.json_file_output, "w") as fh: + json.dump(data, fh) + return True + + +_Plugin = FormInputPlugin diff --git a/saber/webanno/webanno/plugins/__init__.py b/saber/webanno/webanno/plugins/__init__.py new file mode 100644 index 0000000..e8bdccc --- /dev/null +++ b/saber/webanno/webanno/plugins/__init__.py @@ -0,0 +1,3 @@ +from .plugin import Plugin +from .ButtonPressPlugin import ButtonPressPlugin +from .FormInputPlugin import FormInputPlugin diff --git a/saber/webanno/webanno/plugins/plugin.py b/saber/webanno/webanno/plugins/plugin.py new file mode 100644 index 0000000..e0a204f --- /dev/null +++ b/saber/webanno/webanno/plugins/plugin.py @@ -0,0 +1,13 @@ +import abc + + +class Plugin(abc.ABC): + def prompt(self) -> str: + ... + + def collect(self, data: dict, response): + # response is a flask response + ... + + def routes(self): + return {} diff --git a/saber/webanno/webanno/plugins/templates/BossVolumePlugin.html b/saber/webanno/webanno/plugins/templates/BossVolumePlugin.html new file mode 100644 index 0000000..ebaa6a4 --- /dev/null +++ b/saber/webanno/webanno/plugins/templates/BossVolumePlugin.html @@ -0,0 +1,98 @@ + + + + + + +

[[PROMPT]]

+
+ + + +